feat(alertas): add alerts CRUD with stats and management UI

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Consultoria AS
2026-01-22 03:00:14 +00:00
parent 6d59c8d842
commit 9b8aaea7eb
31 changed files with 4892 additions and 0 deletions

View File

@@ -0,0 +1,108 @@
import { prisma } from '../config/database.js';
import type { AlertaFull, AlertaCreate, AlertaUpdate, AlertasStats } from '@horux/shared';
export async function getAlertas(
schema: string,
filters: { leida?: boolean; resuelta?: boolean; prioridad?: string }
): Promise<AlertaFull[]> {
let whereClause = 'WHERE 1=1';
const params: any[] = [];
let paramIndex = 1;
if (filters.leida !== undefined) {
whereClause += ` AND leida = $${paramIndex++}`;
params.push(filters.leida);
}
if (filters.resuelta !== undefined) {
whereClause += ` AND resuelta = $${paramIndex++}`;
params.push(filters.resuelta);
}
if (filters.prioridad) {
whereClause += ` AND prioridad = $${paramIndex++}`;
params.push(filters.prioridad);
}
const alertas = await prisma.$queryRawUnsafe<AlertaFull[]>(`
SELECT id, tipo, titulo, mensaje, prioridad,
fecha_vencimiento as "fechaVencimiento",
leida, resuelta, created_at as "createdAt"
FROM "${schema}".alertas
${whereClause}
ORDER BY
CASE prioridad WHEN 'alta' THEN 1 WHEN 'media' THEN 2 ELSE 3 END,
created_at DESC
`, ...params);
return alertas;
}
export async function getAlertaById(schema: string, id: number): Promise<AlertaFull | null> {
const [alerta] = await prisma.$queryRawUnsafe<AlertaFull[]>(`
SELECT id, tipo, titulo, mensaje, prioridad,
fecha_vencimiento as "fechaVencimiento",
leida, resuelta, created_at as "createdAt"
FROM "${schema}".alertas
WHERE id = $1
`, id);
return alerta || null;
}
export async function createAlerta(schema: string, data: AlertaCreate): Promise<AlertaFull> {
const [alerta] = await prisma.$queryRawUnsafe<AlertaFull[]>(`
INSERT INTO "${schema}".alertas (tipo, titulo, mensaje, prioridad, fecha_vencimiento)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, tipo, titulo, mensaje, prioridad,
fecha_vencimiento as "fechaVencimiento",
leida, resuelta, created_at as "createdAt"
`, data.tipo, data.titulo, data.mensaje, data.prioridad, data.fechaVencimiento || null);
return alerta;
}
export async function updateAlerta(schema: string, id: number, data: AlertaUpdate): Promise<AlertaFull> {
const sets: string[] = [];
const params: any[] = [];
let paramIndex = 1;
if (data.leida !== undefined) {
sets.push(`leida = $${paramIndex++}`);
params.push(data.leida);
}
if (data.resuelta !== undefined) {
sets.push(`resuelta = $${paramIndex++}`);
params.push(data.resuelta);
}
params.push(id);
const [alerta] = await prisma.$queryRawUnsafe<AlertaFull[]>(`
UPDATE "${schema}".alertas
SET ${sets.join(', ')}
WHERE id = $${paramIndex}
RETURNING id, tipo, titulo, mensaje, prioridad,
fecha_vencimiento as "fechaVencimiento",
leida, resuelta, created_at as "createdAt"
`, ...params);
return alerta;
}
export async function deleteAlerta(schema: string, id: number): Promise<void> {
await prisma.$queryRawUnsafe(`DELETE FROM "${schema}".alertas WHERE id = $1`, id);
}
export async function getStats(schema: string): Promise<AlertasStats> {
const [stats] = await prisma.$queryRawUnsafe<AlertasStats[]>(`
SELECT
COUNT(*)::int as total,
COUNT(CASE WHEN leida = false THEN 1 END)::int as "noLeidas",
COUNT(CASE WHEN prioridad = 'alta' AND resuelta = false THEN 1 END)::int as alta,
COUNT(CASE WHEN prioridad = 'media' AND resuelta = false THEN 1 END)::int as media,
COUNT(CASE WHEN prioridad = 'baja' AND resuelta = false THEN 1 END)::int as baja
FROM "${schema}".alertas
`);
return stats;
}
export async function markAllAsRead(schema: string): Promise<void> {
await prisma.$queryRawUnsafe(`UPDATE "${schema}".alertas SET leida = true WHERE leida = false`);
}

View File

@@ -0,0 +1,102 @@
import { prisma } from '../config/database.js';
import type { EventoFiscal, EventoCreate, EventoUpdate } from '@horux/shared';
export async function getEventos(
schema: string,
año: number,
mes?: number
): Promise<EventoFiscal[]> {
let whereClause = `WHERE EXTRACT(YEAR FROM fecha_limite) = $1`;
const params: any[] = [año];
if (mes) {
whereClause += ` AND EXTRACT(MONTH FROM fecha_limite) = $2`;
params.push(mes);
}
const eventos = await prisma.$queryRawUnsafe<EventoFiscal[]>(`
SELECT id, titulo, descripcion, tipo,
fecha_limite as "fechaLimite",
recurrencia, completado, notas,
created_at as "createdAt"
FROM "${schema}".calendario_fiscal
${whereClause}
ORDER BY fecha_limite ASC
`, ...params);
return eventos;
}
export async function getProximosEventos(schema: string, dias = 30): Promise<EventoFiscal[]> {
const eventos = await prisma.$queryRawUnsafe<EventoFiscal[]>(`
SELECT id, titulo, descripcion, tipo,
fecha_limite as "fechaLimite",
recurrencia, completado, notas,
created_at as "createdAt"
FROM "${schema}".calendario_fiscal
WHERE completado = false
AND fecha_limite BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '${dias} days'
ORDER BY fecha_limite ASC
`);
return eventos;
}
export async function createEvento(schema: string, data: EventoCreate): Promise<EventoFiscal> {
const [evento] = await prisma.$queryRawUnsafe<EventoFiscal[]>(`
INSERT INTO "${schema}".calendario_fiscal
(titulo, descripcion, tipo, fecha_limite, recurrencia, notas)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, titulo, descripcion, tipo,
fecha_limite as "fechaLimite",
recurrencia, completado, notas,
created_at as "createdAt"
`, data.titulo, data.descripcion, data.tipo, data.fechaLimite, data.recurrencia, data.notas || null);
return evento;
}
export async function updateEvento(schema: string, id: number, data: EventoUpdate): Promise<EventoFiscal> {
const sets: string[] = [];
const params: any[] = [];
let paramIndex = 1;
if (data.titulo !== undefined) {
sets.push(`titulo = $${paramIndex++}`);
params.push(data.titulo);
}
if (data.descripcion !== undefined) {
sets.push(`descripcion = $${paramIndex++}`);
params.push(data.descripcion);
}
if (data.fechaLimite !== undefined) {
sets.push(`fecha_limite = $${paramIndex++}`);
params.push(data.fechaLimite);
}
if (data.completado !== undefined) {
sets.push(`completado = $${paramIndex++}`);
params.push(data.completado);
}
if (data.notas !== undefined) {
sets.push(`notas = $${paramIndex++}`);
params.push(data.notas);
}
params.push(id);
const [evento] = await prisma.$queryRawUnsafe<EventoFiscal[]>(`
UPDATE "${schema}".calendario_fiscal
SET ${sets.join(', ')}
WHERE id = $${paramIndex}
RETURNING id, titulo, descripcion, tipo,
fecha_limite as "fechaLimite",
recurrencia, completado, notas,
created_at as "createdAt"
`, ...params);
return evento;
}
export async function deleteEvento(schema: string, id: number): Promise<void> {
await prisma.$queryRawUnsafe(`DELETE FROM "${schema}".calendario_fiscal WHERE id = $1`, id);
}

View File

@@ -0,0 +1,170 @@
import { prisma } from '../config/database.js';
import type { EstadoResultados, FlujoEfectivo, ComparativoPeriodos, ConcentradoRfc } from '@horux/shared';
export async function getEstadoResultados(
schema: string,
fechaInicio: string,
fechaFin: string
): Promise<EstadoResultados> {
const ingresos = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; total: number }[]>(`
SELECT rfc_receptor as rfc, nombre_receptor as nombre, SUM(subtotal) as total
FROM "${schema}".cfdis
WHERE tipo = 'ingreso' AND estado = 'vigente'
AND fecha_emision BETWEEN $1 AND $2
GROUP BY rfc_receptor, nombre_receptor
ORDER BY total DESC LIMIT 10
`, fechaInicio, fechaFin);
const egresos = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; total: number }[]>(`
SELECT rfc_emisor as rfc, nombre_emisor as nombre, SUM(subtotal) as total
FROM "${schema}".cfdis
WHERE tipo = 'egreso' AND estado = 'vigente'
AND fecha_emision BETWEEN $1 AND $2
GROUP BY rfc_emisor, nombre_emisor
ORDER BY total DESC LIMIT 10
`, fechaInicio, fechaFin);
const [totales] = await prisma.$queryRawUnsafe<[{ ingresos: number; egresos: number; iva: number }]>(`
SELECT
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN subtotal ELSE 0 END), 0) as ingresos,
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN subtotal ELSE 0 END), 0) as egresos,
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) -
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as iva
FROM "${schema}".cfdis
WHERE estado = 'vigente' AND fecha_emision BETWEEN $1 AND $2
`, fechaInicio, fechaFin);
const totalIngresos = Number(totales?.ingresos || 0);
const totalEgresos = Number(totales?.egresos || 0);
const utilidadBruta = totalIngresos - totalEgresos;
const impuestos = Number(totales?.iva || 0);
return {
periodo: { inicio: fechaInicio, fin: fechaFin },
ingresos: ingresos.map(i => ({ concepto: i.nombre, monto: Number(i.total) })),
egresos: egresos.map(e => ({ concepto: e.nombre, monto: Number(e.total) })),
totalIngresos,
totalEgresos,
utilidadBruta,
impuestos,
utilidadNeta: utilidadBruta - (impuestos > 0 ? impuestos : 0),
};
}
export async function getFlujoEfectivo(
schema: string,
fechaInicio: string,
fechaFin: string
): Promise<FlujoEfectivo> {
const entradas = await prisma.$queryRawUnsafe<{ mes: string; total: number }[]>(`
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, SUM(total) as total
FROM "${schema}".cfdis
WHERE tipo = 'ingreso' AND estado = 'vigente'
AND fecha_emision BETWEEN $1 AND $2
GROUP BY TO_CHAR(fecha_emision, 'YYYY-MM')
ORDER BY mes
`, fechaInicio, fechaFin);
const salidas = await prisma.$queryRawUnsafe<{ mes: string; total: number }[]>(`
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, SUM(total) as total
FROM "${schema}".cfdis
WHERE tipo = 'egreso' AND estado = 'vigente'
AND fecha_emision BETWEEN $1 AND $2
GROUP BY TO_CHAR(fecha_emision, 'YYYY-MM')
ORDER BY mes
`, fechaInicio, fechaFin);
const totalEntradas = entradas.reduce((sum, e) => sum + Number(e.total), 0);
const totalSalidas = salidas.reduce((sum, s) => sum + Number(s.total), 0);
return {
periodo: { inicio: fechaInicio, fin: fechaFin },
saldoInicial: 0,
entradas: entradas.map(e => ({ concepto: e.mes, monto: Number(e.total) })),
salidas: salidas.map(s => ({ concepto: s.mes, monto: Number(s.total) })),
totalEntradas,
totalSalidas,
flujoNeto: totalEntradas - totalSalidas,
saldoFinal: totalEntradas - totalSalidas,
};
}
export async function getComparativo(
schema: string,
año: number
): Promise<ComparativoPeriodos> {
const actual = await prisma.$queryRawUnsafe<{ mes: number; ingresos: number; egresos: number }[]>(`
SELECT EXTRACT(MONTH FROM fecha_emision)::int as mes,
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN total ELSE 0 END), 0) as ingresos,
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as egresos
FROM "${schema}".cfdis
WHERE estado = 'vigente' AND EXTRACT(YEAR FROM fecha_emision) = $1
GROUP BY mes ORDER BY mes
`, año);
const anterior = await prisma.$queryRawUnsafe<{ mes: number; ingresos: number; egresos: number }[]>(`
SELECT EXTRACT(MONTH FROM fecha_emision)::int as mes,
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN total ELSE 0 END), 0) as ingresos,
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as egresos
FROM "${schema}".cfdis
WHERE estado = 'vigente' AND EXTRACT(YEAR FROM fecha_emision) = $1
GROUP BY mes ORDER BY mes
`, año - 1);
const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
const ingresos = meses.map((_, i) => Number(actual.find(a => a.mes === i + 1)?.ingresos || 0));
const egresos = meses.map((_, i) => Number(actual.find(a => a.mes === i + 1)?.egresos || 0));
const utilidad = ingresos.map((ing, i) => ing - egresos[i]);
const totalActualIng = ingresos.reduce((a, b) => a + b, 0);
const totalAnteriorIng = anterior.reduce((a, b) => a + Number(b.ingresos), 0);
const totalActualEgr = egresos.reduce((a, b) => a + b, 0);
const totalAnteriorEgr = anterior.reduce((a, b) => a + Number(b.egresos), 0);
return {
periodos: meses,
ingresos,
egresos,
utilidad,
variacionIngresos: totalAnteriorIng > 0 ? ((totalActualIng - totalAnteriorIng) / totalAnteriorIng) * 100 : 0,
variacionEgresos: totalAnteriorEgr > 0 ? ((totalActualEgr - totalAnteriorEgr) / totalAnteriorEgr) * 100 : 0,
variacionUtilidad: 0,
};
}
export async function getConcentradoRfc(
schema: string,
fechaInicio: string,
fechaFin: string,
tipo: 'cliente' | 'proveedor'
): Promise<ConcentradoRfc[]> {
if (tipo === 'cliente') {
const data = await prisma.$queryRawUnsafe<ConcentradoRfc[]>(`
SELECT rfc_receptor as rfc, nombre_receptor as nombre,
'cliente' as tipo,
SUM(total) as "totalFacturado",
SUM(iva) as "totalIva",
COUNT(*)::int as "cantidadCfdis"
FROM "${schema}".cfdis
WHERE tipo = 'ingreso' AND estado = 'vigente'
AND fecha_emision BETWEEN $1 AND $2
GROUP BY rfc_receptor, nombre_receptor
ORDER BY "totalFacturado" DESC
`, fechaInicio, fechaFin);
return data.map(d => ({ ...d, totalFacturado: Number(d.totalFacturado), totalIva: Number(d.totalIva) }));
} else {
const data = await prisma.$queryRawUnsafe<ConcentradoRfc[]>(`
SELECT rfc_emisor as rfc, nombre_emisor as nombre,
'proveedor' as tipo,
SUM(total) as "totalFacturado",
SUM(iva) as "totalIva",
COUNT(*)::int as "cantidadCfdis"
FROM "${schema}".cfdis
WHERE tipo = 'egreso' AND estado = 'vigente'
AND fecha_emision BETWEEN $1 AND $2
GROUP BY rfc_emisor, nombre_emisor
ORDER BY "totalFacturado" DESC
`, fechaInicio, fechaFin);
return data.map(d => ({ ...d, totalFacturado: Number(d.totalFacturado), totalIva: Number(d.totalIva) }));
}
}

View File

@@ -0,0 +1,107 @@
import { prisma } from '../config/database.js';
import bcrypt from 'bcryptjs';
import type { UserListItem, UserInvite, UserUpdate } from '@horux/shared';
export async function getUsuarios(tenantId: string): Promise<UserListItem[]> {
const users = await prisma.user.findMany({
where: { tenantId },
select: {
id: true,
email: true,
nombre: true,
role: true,
active: true,
lastLogin: true,
createdAt: true,
},
orderBy: { createdAt: 'desc' },
});
return users.map(u => ({
...u,
lastLogin: u.lastLogin?.toISOString() || null,
createdAt: u.createdAt.toISOString(),
}));
}
export async function inviteUsuario(tenantId: string, data: UserInvite): Promise<UserListItem> {
// Check tenant user limit
const tenant = await prisma.tenant.findUnique({
where: { id: tenantId },
select: { usersLimit: true },
});
const currentCount = await prisma.user.count({ where: { tenantId } });
if (currentCount >= (tenant?.usersLimit || 1)) {
throw new Error('Límite de usuarios alcanzado para este plan');
}
// Generate temporary password
const tempPassword = Math.random().toString(36).slice(-8);
const passwordHash = await bcrypt.hash(tempPassword, 12);
const user = await prisma.user.create({
data: {
tenantId,
email: data.email,
passwordHash,
nombre: data.nombre,
role: data.role,
},
select: {
id: true,
email: true,
nombre: true,
role: true,
active: true,
lastLogin: true,
createdAt: true,
},
});
// In production, send email with tempPassword
console.log(`Temporary password for ${data.email}: ${tempPassword}`);
return {
...user,
lastLogin: user.lastLogin?.toISOString() || null,
createdAt: user.createdAt.toISOString(),
};
}
export async function updateUsuario(
tenantId: string,
userId: string,
data: UserUpdate
): Promise<UserListItem> {
const user = await prisma.user.update({
where: { id: userId, tenantId },
data: {
...(data.nombre && { nombre: data.nombre }),
...(data.role && { role: data.role }),
...(data.active !== undefined && { active: data.active }),
},
select: {
id: true,
email: true,
nombre: true,
role: true,
active: true,
lastLogin: true,
createdAt: true,
},
});
return {
...user,
lastLogin: user.lastLogin?.toISOString() || null,
createdAt: user.createdAt.toISOString(),
};
}
export async function deleteUsuario(tenantId: string, userId: string): Promise<void> {
await prisma.user.delete({
where: { id: userId, tenantId },
});
}