From 9b8aaea7ebfecbbf66fc387c01dfd3654d0788ba Mon Sep 17 00:00:00 2001 From: Consultoria AS Date: Thu, 22 Jan 2026 03:00:14 +0000 Subject: [PATCH] feat(alertas): add alerts CRUD with stats and management UI Co-Authored-By: Claude Opus 4.5 --- apps/api/src/app.ts | 8 + .../api/src/controllers/alertas.controller.ts | 73 + .../src/controllers/calendario.controller.ts | 52 + .../src/controllers/reportes.controller.ts | 55 + .../src/controllers/usuarios.controller.ts | 51 + apps/api/src/routes/alertas.routes.ts | 19 + apps/api/src/routes/calendario.routes.ts | 17 + apps/api/src/routes/reportes.routes.ts | 16 + apps/api/src/routes/usuarios.routes.ts | 14 + apps/api/src/services/alertas.service.ts | 108 + apps/api/src/services/calendario.service.ts | 102 + apps/api/src/services/reportes.service.ts | 170 + apps/api/src/services/usuarios.service.ts | 107 + apps/web/app/(dashboard)/alertas/page.tsx | 208 ++ apps/web/app/(dashboard)/calendario/page.tsx | 205 ++ apps/web/app/(dashboard)/reportes/page.tsx | 283 ++ apps/web/app/(dashboard)/usuarios/page.tsx | 200 ++ apps/web/lib/api/alertas.ts | 33 + apps/web/lib/api/calendario.ts | 28 + apps/web/lib/api/reportes.ts | 36 + apps/web/lib/api/usuarios.ts | 21 + apps/web/lib/hooks/use-alertas.ts | 61 + apps/web/lib/hooks/use-calendario.ts | 47 + apps/web/lib/hooks/use-reportes.ts | 30 + apps/web/lib/hooks/use-usuarios.ts | 40 + docs/plans/2026-01-22-fase3-implementation.md | 2755 +++++++++++++++++ packages/shared/src/index.ts | 3 + packages/shared/src/types/alertas.ts | 35 + packages/shared/src/types/calendario.ts | 37 + packages/shared/src/types/reportes.ts | 46 + packages/shared/src/types/user.ts | 32 + 31 files changed, 4892 insertions(+) create mode 100644 apps/api/src/controllers/alertas.controller.ts create mode 100644 apps/api/src/controllers/calendario.controller.ts create mode 100644 apps/api/src/controllers/reportes.controller.ts create mode 100644 apps/api/src/controllers/usuarios.controller.ts create mode 100644 apps/api/src/routes/alertas.routes.ts create mode 100644 apps/api/src/routes/calendario.routes.ts create mode 100644 apps/api/src/routes/reportes.routes.ts create mode 100644 apps/api/src/routes/usuarios.routes.ts create mode 100644 apps/api/src/services/alertas.service.ts create mode 100644 apps/api/src/services/calendario.service.ts create mode 100644 apps/api/src/services/reportes.service.ts create mode 100644 apps/api/src/services/usuarios.service.ts create mode 100644 apps/web/app/(dashboard)/alertas/page.tsx create mode 100644 apps/web/app/(dashboard)/calendario/page.tsx create mode 100644 apps/web/app/(dashboard)/reportes/page.tsx create mode 100644 apps/web/app/(dashboard)/usuarios/page.tsx create mode 100644 apps/web/lib/api/alertas.ts create mode 100644 apps/web/lib/api/calendario.ts create mode 100644 apps/web/lib/api/reportes.ts create mode 100644 apps/web/lib/api/usuarios.ts create mode 100644 apps/web/lib/hooks/use-alertas.ts create mode 100644 apps/web/lib/hooks/use-calendario.ts create mode 100644 apps/web/lib/hooks/use-reportes.ts create mode 100644 apps/web/lib/hooks/use-usuarios.ts create mode 100644 docs/plans/2026-01-22-fase3-implementation.md create mode 100644 packages/shared/src/types/alertas.ts create mode 100644 packages/shared/src/types/calendario.ts create mode 100644 packages/shared/src/types/reportes.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 9cbdcfc..25bd8b1 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -8,6 +8,10 @@ import { dashboardRoutes } from './routes/dashboard.routes.js'; import { cfdiRoutes } from './routes/cfdi.routes.js'; import { impuestosRoutes } from './routes/impuestos.routes.js'; import { exportRoutes } from './routes/export.routes.js'; +import { alertasRoutes } from './routes/alertas.routes.js'; +import { calendarioRoutes } from './routes/calendario.routes.js'; +import { reportesRoutes } from './routes/reportes.routes.js'; +import { usuariosRoutes } from './routes/usuarios.routes.js'; const app = express(); @@ -33,6 +37,10 @@ app.use('/api/dashboard', dashboardRoutes); app.use('/api/cfdi', cfdiRoutes); app.use('/api/impuestos', impuestosRoutes); app.use('/api/export', exportRoutes); +app.use('/api/alertas', alertasRoutes); +app.use('/api/calendario', calendarioRoutes); +app.use('/api/reportes', reportesRoutes); +app.use('/api/usuarios', usuariosRoutes); // Error handling app.use(errorMiddleware); diff --git a/apps/api/src/controllers/alertas.controller.ts b/apps/api/src/controllers/alertas.controller.ts new file mode 100644 index 0000000..d5c8da4 --- /dev/null +++ b/apps/api/src/controllers/alertas.controller.ts @@ -0,0 +1,73 @@ +import { Request, Response, NextFunction } from 'express'; +import * as alertasService from '../services/alertas.service.js'; + +export async function getAlertas(req: Request, res: Response, next: NextFunction) { + try { + const { leida, resuelta, prioridad } = req.query; + const alertas = await alertasService.getAlertas(req.tenantSchema!, { + leida: leida === 'true' ? true : leida === 'false' ? false : undefined, + resuelta: resuelta === 'true' ? true : resuelta === 'false' ? false : undefined, + prioridad: prioridad as string, + }); + res.json(alertas); + } catch (error) { + next(error); + } +} + +export async function getAlerta(req: Request, res: Response, next: NextFunction) { + try { + const alerta = await alertasService.getAlertaById(req.tenantSchema!, parseInt(req.params.id)); + if (!alerta) { + return res.status(404).json({ message: 'Alerta no encontrada' }); + } + res.json(alerta); + } catch (error) { + next(error); + } +} + +export async function createAlerta(req: Request, res: Response, next: NextFunction) { + try { + const alerta = await alertasService.createAlerta(req.tenantSchema!, req.body); + res.status(201).json(alerta); + } catch (error) { + next(error); + } +} + +export async function updateAlerta(req: Request, res: Response, next: NextFunction) { + try { + const alerta = await alertasService.updateAlerta(req.tenantSchema!, parseInt(req.params.id), req.body); + res.json(alerta); + } catch (error) { + next(error); + } +} + +export async function deleteAlerta(req: Request, res: Response, next: NextFunction) { + try { + await alertasService.deleteAlerta(req.tenantSchema!, parseInt(req.params.id)); + res.status(204).send(); + } catch (error) { + next(error); + } +} + +export async function getStats(req: Request, res: Response, next: NextFunction) { + try { + const stats = await alertasService.getStats(req.tenantSchema!); + res.json(stats); + } catch (error) { + next(error); + } +} + +export async function markAllAsRead(req: Request, res: Response, next: NextFunction) { + try { + await alertasService.markAllAsRead(req.tenantSchema!); + res.json({ success: true }); + } catch (error) { + next(error); + } +} diff --git a/apps/api/src/controllers/calendario.controller.ts b/apps/api/src/controllers/calendario.controller.ts new file mode 100644 index 0000000..426061d --- /dev/null +++ b/apps/api/src/controllers/calendario.controller.ts @@ -0,0 +1,52 @@ +import { Request, Response, NextFunction } from 'express'; +import * as calendarioService from '../services/calendario.service.js'; + +export async function getEventos(req: Request, res: Response, next: NextFunction) { + try { + const { año, mes } = req.query; + const añoNum = parseInt(año as string) || new Date().getFullYear(); + const mesNum = mes ? parseInt(mes as string) : undefined; + + const eventos = await calendarioService.getEventos(req.tenantSchema!, añoNum, mesNum); + res.json(eventos); + } catch (error) { + next(error); + } +} + +export async function getProximos(req: Request, res: Response, next: NextFunction) { + try { + const dias = parseInt(req.query.dias as string) || 30; + const eventos = await calendarioService.getProximosEventos(req.tenantSchema!, dias); + res.json(eventos); + } catch (error) { + next(error); + } +} + +export async function createEvento(req: Request, res: Response, next: NextFunction) { + try { + const evento = await calendarioService.createEvento(req.tenantSchema!, req.body); + res.status(201).json(evento); + } catch (error) { + next(error); + } +} + +export async function updateEvento(req: Request, res: Response, next: NextFunction) { + try { + const evento = await calendarioService.updateEvento(req.tenantSchema!, parseInt(req.params.id), req.body); + res.json(evento); + } catch (error) { + next(error); + } +} + +export async function deleteEvento(req: Request, res: Response, next: NextFunction) { + try { + await calendarioService.deleteEvento(req.tenantSchema!, parseInt(req.params.id)); + res.status(204).send(); + } catch (error) { + next(error); + } +} diff --git a/apps/api/src/controllers/reportes.controller.ts b/apps/api/src/controllers/reportes.controller.ts new file mode 100644 index 0000000..a2ecf04 --- /dev/null +++ b/apps/api/src/controllers/reportes.controller.ts @@ -0,0 +1,55 @@ +import { Request, Response, NextFunction } from 'express'; +import * as reportesService from '../services/reportes.service.js'; + +export async function getEstadoResultados(req: Request, res: Response, next: NextFunction) { + try { + const { fechaInicio, fechaFin } = req.query; + const now = new Date(); + const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`; + const fin = (fechaFin as string) || now.toISOString().split('T')[0]; + + const data = await reportesService.getEstadoResultados(req.tenantSchema!, inicio, fin); + res.json(data); + } catch (error) { + next(error); + } +} + +export async function getFlujoEfectivo(req: Request, res: Response, next: NextFunction) { + try { + const { fechaInicio, fechaFin } = req.query; + const now = new Date(); + const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`; + const fin = (fechaFin as string) || now.toISOString().split('T')[0]; + + const data = await reportesService.getFlujoEfectivo(req.tenantSchema!, inicio, fin); + res.json(data); + } catch (error) { + next(error); + } +} + +export async function getComparativo(req: Request, res: Response, next: NextFunction) { + try { + const año = parseInt(req.query.año as string) || new Date().getFullYear(); + const data = await reportesService.getComparativo(req.tenantSchema!, año); + res.json(data); + } catch (error) { + next(error); + } +} + +export async function getConcentradoRfc(req: Request, res: Response, next: NextFunction) { + try { + const { fechaInicio, fechaFin, tipo } = req.query; + const now = new Date(); + const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`; + const fin = (fechaFin as string) || now.toISOString().split('T')[0]; + const tipoRfc = (tipo as 'cliente' | 'proveedor') || 'cliente'; + + const data = await reportesService.getConcentradoRfc(req.tenantSchema!, inicio, fin, tipoRfc); + res.json(data); + } catch (error) { + next(error); + } +} diff --git a/apps/api/src/controllers/usuarios.controller.ts b/apps/api/src/controllers/usuarios.controller.ts new file mode 100644 index 0000000..57cf2c9 --- /dev/null +++ b/apps/api/src/controllers/usuarios.controller.ts @@ -0,0 +1,51 @@ +import { Request, Response, NextFunction } from 'express'; +import * as usuariosService from '../services/usuarios.service.js'; +import { AppError } from '../utils/errors.js'; + +export async function getUsuarios(req: Request, res: Response, next: NextFunction) { + try { + const usuarios = await usuariosService.getUsuarios(req.user!.tenantId); + res.json(usuarios); + } catch (error) { + next(error); + } +} + +export async function inviteUsuario(req: Request, res: Response, next: NextFunction) { + try { + if (req.user!.role !== 'admin') { + throw new AppError(403, 'Solo administradores pueden invitar usuarios'); + } + const usuario = await usuariosService.inviteUsuario(req.user!.tenantId, req.body); + res.status(201).json(usuario); + } catch (error) { + next(error); + } +} + +export async function updateUsuario(req: Request, res: Response, next: NextFunction) { + try { + if (req.user!.role !== 'admin') { + throw new AppError(403, 'Solo administradores pueden modificar usuarios'); + } + const usuario = await usuariosService.updateUsuario(req.user!.tenantId, req.params.id, req.body); + res.json(usuario); + } catch (error) { + next(error); + } +} + +export async function deleteUsuario(req: Request, res: Response, next: NextFunction) { + try { + if (req.user!.role !== 'admin') { + throw new AppError(403, 'Solo administradores pueden eliminar usuarios'); + } + if (req.params.id === req.user!.id) { + throw new AppError(400, 'No puedes eliminar tu propia cuenta'); + } + await usuariosService.deleteUsuario(req.user!.tenantId, req.params.id); + res.status(204).send(); + } catch (error) { + next(error); + } +} diff --git a/apps/api/src/routes/alertas.routes.ts b/apps/api/src/routes/alertas.routes.ts new file mode 100644 index 0000000..0acc4b5 --- /dev/null +++ b/apps/api/src/routes/alertas.routes.ts @@ -0,0 +1,19 @@ +import { Router } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import * as alertasController from '../controllers/alertas.controller.js'; + +const router = Router(); + +router.use(authenticate); +router.use(tenantMiddleware); + +router.get('/', alertasController.getAlertas); +router.get('/stats', alertasController.getStats); +router.post('/mark-all-read', alertasController.markAllAsRead); +router.get('/:id', alertasController.getAlerta); +router.post('/', alertasController.createAlerta); +router.patch('/:id', alertasController.updateAlerta); +router.delete('/:id', alertasController.deleteAlerta); + +export { router as alertasRoutes }; diff --git a/apps/api/src/routes/calendario.routes.ts b/apps/api/src/routes/calendario.routes.ts new file mode 100644 index 0000000..17022c3 --- /dev/null +++ b/apps/api/src/routes/calendario.routes.ts @@ -0,0 +1,17 @@ +import { Router } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import * as calendarioController from '../controllers/calendario.controller.js'; + +const router = Router(); + +router.use(authenticate); +router.use(tenantMiddleware); + +router.get('/', calendarioController.getEventos); +router.get('/proximos', calendarioController.getProximos); +router.post('/', calendarioController.createEvento); +router.patch('/:id', calendarioController.updateEvento); +router.delete('/:id', calendarioController.deleteEvento); + +export { router as calendarioRoutes }; diff --git a/apps/api/src/routes/reportes.routes.ts b/apps/api/src/routes/reportes.routes.ts new file mode 100644 index 0000000..5be1d42 --- /dev/null +++ b/apps/api/src/routes/reportes.routes.ts @@ -0,0 +1,16 @@ +import { Router } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import * as reportesController from '../controllers/reportes.controller.js'; + +const router = Router(); + +router.use(authenticate); +router.use(tenantMiddleware); + +router.get('/estado-resultados', reportesController.getEstadoResultados); +router.get('/flujo-efectivo', reportesController.getFlujoEfectivo); +router.get('/comparativo', reportesController.getComparativo); +router.get('/concentrado-rfc', reportesController.getConcentradoRfc); + +export { router as reportesRoutes }; diff --git a/apps/api/src/routes/usuarios.routes.ts b/apps/api/src/routes/usuarios.routes.ts new file mode 100644 index 0000000..a9804e0 --- /dev/null +++ b/apps/api/src/routes/usuarios.routes.ts @@ -0,0 +1,14 @@ +import { Router } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import * as usuariosController from '../controllers/usuarios.controller.js'; + +const router = Router(); + +router.use(authenticate); + +router.get('/', usuariosController.getUsuarios); +router.post('/invite', usuariosController.inviteUsuario); +router.patch('/:id', usuariosController.updateUsuario); +router.delete('/:id', usuariosController.deleteUsuario); + +export { router as usuariosRoutes }; diff --git a/apps/api/src/services/alertas.service.ts b/apps/api/src/services/alertas.service.ts new file mode 100644 index 0000000..911c1c8 --- /dev/null +++ b/apps/api/src/services/alertas.service.ts @@ -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 { + 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(` + 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 { + const [alerta] = await prisma.$queryRawUnsafe(` + 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 { + const [alerta] = await prisma.$queryRawUnsafe(` + 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 { + 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(` + 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 { + await prisma.$queryRawUnsafe(`DELETE FROM "${schema}".alertas WHERE id = $1`, id); +} + +export async function getStats(schema: string): Promise { + const [stats] = await prisma.$queryRawUnsafe(` + 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 { + await prisma.$queryRawUnsafe(`UPDATE "${schema}".alertas SET leida = true WHERE leida = false`); +} diff --git a/apps/api/src/services/calendario.service.ts b/apps/api/src/services/calendario.service.ts new file mode 100644 index 0000000..7e112d4 --- /dev/null +++ b/apps/api/src/services/calendario.service.ts @@ -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 { + 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(` + 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 { + const eventos = await prisma.$queryRawUnsafe(` + 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 { + const [evento] = await prisma.$queryRawUnsafe(` + 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 { + 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(` + 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 { + await prisma.$queryRawUnsafe(`DELETE FROM "${schema}".calendario_fiscal WHERE id = $1`, id); +} diff --git a/apps/api/src/services/reportes.service.ts b/apps/api/src/services/reportes.service.ts new file mode 100644 index 0000000..8079082 --- /dev/null +++ b/apps/api/src/services/reportes.service.ts @@ -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 { + 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 { + 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 { + 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 { + if (tipo === 'cliente') { + const data = await prisma.$queryRawUnsafe(` + 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(` + 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) })); + } +} diff --git a/apps/api/src/services/usuarios.service.ts b/apps/api/src/services/usuarios.service.ts new file mode 100644 index 0000000..21129b5 --- /dev/null +++ b/apps/api/src/services/usuarios.service.ts @@ -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 { + 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 { + // 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 { + 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 { + await prisma.user.delete({ + where: { id: userId, tenantId }, + }); +} diff --git a/apps/web/app/(dashboard)/alertas/page.tsx b/apps/web/app/(dashboard)/alertas/page.tsx new file mode 100644 index 0000000..c6321a6 --- /dev/null +++ b/apps/web/app/(dashboard)/alertas/page.tsx @@ -0,0 +1,208 @@ +'use client'; + +import { useState } from 'react'; +import { DashboardShell } from '@/components/layouts/dashboard-shell'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { useAlertas, useAlertasStats, useUpdateAlerta, useDeleteAlerta, useMarkAllAsRead } from '@/lib/hooks/use-alertas'; +import { Bell, Check, Trash2, AlertTriangle, Info, AlertCircle, CheckCircle } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +const prioridadStyles = { + alta: 'border-l-4 border-l-destructive bg-destructive/5', + media: 'border-l-4 border-l-warning bg-warning/5', + baja: 'border-l-4 border-l-muted bg-muted/5', +}; + +const prioridadIcons = { + alta: AlertCircle, + media: AlertTriangle, + baja: Info, +}; + +export default function AlertasPage() { + const [filter, setFilter] = useState<'todas' | 'pendientes' | 'resueltas'>('pendientes'); + const { data: alertas, isLoading } = useAlertas({ + resuelta: filter === 'resueltas' ? true : filter === 'pendientes' ? false : undefined, + }); + const { data: stats } = useAlertasStats(); + const updateAlerta = useUpdateAlerta(); + const deleteAlerta = useDeleteAlerta(); + const markAllAsRead = useMarkAllAsRead(); + + const handleMarkAsRead = (id: number) => { + updateAlerta.mutate({ id, data: { leida: true } }); + }; + + const handleResolve = (id: number) => { + updateAlerta.mutate({ id, data: { resuelta: true } }); + }; + + const handleDelete = (id: number) => { + if (confirm('¿Eliminar esta alerta?')) { + deleteAlerta.mutate(id); + } + }; + + return ( + +
+ {/* Stats */} +
+ + + Total + + + +
{stats?.total || 0}
+
+
+ + + No Leidas + + + +
{stats?.noLeidas || 0}
+
+
+ + + Alta Prioridad + + + +
{stats?.alta || 0}
+
+
+ + + Pendientes + + + +
{(stats?.alta || 0) + (stats?.media || 0) + (stats?.baja || 0)}
+
+
+
+ + {/* Filters */} +
+ + + +
+ +
+ + {/* Alertas List */} +
+ {isLoading ? ( +
Cargando...
+ ) : alertas?.length === 0 ? ( + + + +

No hay alertas {filter === 'pendientes' ? 'pendientes' : ''}

+
+
+ ) : ( + alertas?.map((alerta) => { + const Icon = prioridadIcons[alerta.prioridad]; + return ( + + +
+ +
+
+

{alerta.titulo}

+ {!alerta.leida && ( + + Nueva + + )} +
+

{alerta.mensaje}

+
+ {new Date(alerta.createdAt).toLocaleDateString('es-MX')} + {alerta.fechaVencimiento && ( + Vence: {new Date(alerta.fechaVencimiento).toLocaleDateString('es-MX')} + )} +
+
+
+ {!alerta.leida && ( + + )} + {!alerta.resuelta && ( + + )} + +
+
+
+
+ ); + }) + )} +
+
+ + ); +} diff --git a/apps/web/app/(dashboard)/calendario/page.tsx b/apps/web/app/(dashboard)/calendario/page.tsx new file mode 100644 index 0000000..1f6668a --- /dev/null +++ b/apps/web/app/(dashboard)/calendario/page.tsx @@ -0,0 +1,205 @@ +'use client'; + +import { useState } from 'react'; +import { DashboardShell } from '@/components/layouts/dashboard-shell'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { useEventos, useUpdateEvento } from '@/lib/hooks/use-calendario'; +import { Calendar, ChevronLeft, ChevronRight, Check, Clock, FileText, CreditCard } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +const meses = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre']; + +const tipoIcons = { + declaracion: FileText, + pago: CreditCard, + obligacion: Clock, + custom: Calendar, +}; + +const tipoColors = { + declaracion: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', + pago: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', + obligacion: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200', + custom: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200', +}; + +export default function CalendarioPage() { + const [año, setAño] = useState(new Date().getFullYear()); + const [mes, setMes] = useState(new Date().getMonth() + 1); + const { data: eventos, isLoading } = useEventos(año, mes); + const updateEvento = useUpdateEvento(); + + const handlePrevMonth = () => { + if (mes === 1) { + setMes(12); + setAño(año - 1); + } else { + setMes(mes - 1); + } + }; + + const handleNextMonth = () => { + if (mes === 12) { + setMes(1); + setAño(año + 1); + } else { + setMes(mes + 1); + } + }; + + const handleToggleComplete = (id: number, completado: boolean) => { + updateEvento.mutate({ id, data: { completado: !completado } }); + }; + + // Generate calendar days + const firstDay = new Date(año, mes - 1, 1).getDay(); + const daysInMonth = new Date(año, mes, 0).getDate(); + const days = Array.from({ length: 42 }, (_, i) => { + const day = i - firstDay + 1; + if (day < 1 || day > daysInMonth) return null; + return day; + }); + + const getEventosForDay = (day: number) => { + return eventos?.filter(e => { + const fecha = new Date(e.fechaLimite); + return fecha.getDate() === day; + }) || []; + }; + + return ( + +
+ {/* Calendar */} + + + + + {meses[mes - 1]} {año} + +
+ + +
+
+ +
+ {['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'].map(d => ( +
+ {d} +
+ ))} + {days.map((day, i) => { + const dayEventos = day ? getEventosForDay(day) : []; + const isToday = day === new Date().getDate() && mes === new Date().getMonth() + 1 && año === new Date().getFullYear(); + return ( +
+ {day && ( + <> +
{day}
+
+ {dayEventos.slice(0, 2).map(e => { + const Icon = tipoIcons[e.tipo]; + return ( +
+ + {e.titulo} +
+ ); + })} + {dayEventos.length > 2 && ( +
+{dayEventos.length - 2} más
+ )} +
+ + )} +
+ ); + })} +
+
+
+ + {/* Event List */} + + + Eventos del Mes + + + {isLoading ? ( +
Cargando...
+ ) : eventos?.length === 0 ? ( +
No hay eventos este mes
+ ) : ( +
+ {eventos?.map(evento => { + const Icon = tipoIcons[evento.tipo]; + return ( +
+
+
+
+ +
+
+

+ {evento.titulo} +

+

{evento.descripcion}

+

+ {new Date(evento.fechaLimite).toLocaleDateString('es-MX', { + day: 'numeric', + month: 'short', + })} +

+
+
+ +
+
+ ); + })} +
+ )} +
+
+
+
+ ); +} diff --git a/apps/web/app/(dashboard)/reportes/page.tsx b/apps/web/app/(dashboard)/reportes/page.tsx new file mode 100644 index 0000000..1044a09 --- /dev/null +++ b/apps/web/app/(dashboard)/reportes/page.tsx @@ -0,0 +1,283 @@ +'use client'; + +import { useState } from 'react'; +import { DashboardShell } from '@/components/layouts/dashboard-shell'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { useEstadoResultados, useFlujoEfectivo, useComparativo, useConcentradoRfc } from '@/lib/hooks/use-reportes'; +import { BarChart } from '@/components/charts/bar-chart'; +import { formatCurrency } from '@/lib/utils'; +import { FileText, TrendingUp, TrendingDown, Users } from 'lucide-react'; + +export default function ReportesPage() { + const [año] = useState(new Date().getFullYear()); + const fechaInicio = `${año}-01-01`; + const fechaFin = `${año}-12-31`; + + const { data: estadoResultados, isLoading: loadingER } = useEstadoResultados(fechaInicio, fechaFin); + const { data: flujoEfectivo, isLoading: loadingFE } = useFlujoEfectivo(fechaInicio, fechaFin); + const { data: comparativo, isLoading: loadingComp } = useComparativo(año); + const { data: clientes } = useConcentradoRfc('cliente', fechaInicio, fechaFin); + const { data: proveedores } = useConcentradoRfc('proveedor', fechaInicio, fechaFin); + + return ( + + + + Estado de Resultados + Flujo de Efectivo + Comparativo + Concentrado RFC + + + + {loadingER ? ( +
Cargando...
+ ) : estadoResultados ? ( + <> +
+ + + Total Ingresos + + + +
+ {formatCurrency(estadoResultados.totalIngresos)} +
+
+
+ + + Total Egresos + + + +
+ {formatCurrency(estadoResultados.totalEgresos)} +
+
+
+ + + Utilidad Bruta + + + +
= 0 ? 'text-success' : 'text-destructive'}`}> + {formatCurrency(estadoResultados.utilidadBruta)} +
+
+
+ + + Utilidad Neta + + + +
= 0 ? 'text-success' : 'text-destructive'}`}> + {formatCurrency(estadoResultados.utilidadNeta)} +
+
+
+
+ +
+ + + Top 10 Ingresos por Cliente + + +
+ {estadoResultados.ingresos.map((item, i) => ( +
+ {item.concepto} + {formatCurrency(item.monto)} +
+ ))} +
+
+
+ + + Top 10 Egresos por Proveedor + + +
+ {estadoResultados.egresos.map((item, i) => ( +
+ {item.concepto} + {formatCurrency(item.monto)} +
+ ))} +
+
+
+
+ + ) : null} +
+ + + {loadingFE ? ( +
Cargando...
+ ) : flujoEfectivo ? ( + <> +
+ + + Total Entradas + + +
+ {formatCurrency(flujoEfectivo.totalEntradas)} +
+
+
+ + + Total Salidas + + +
+ {formatCurrency(flujoEfectivo.totalSalidas)} +
+
+
+ + + Flujo Neto + + +
= 0 ? 'text-success' : 'text-destructive'}`}> + {formatCurrency(flujoEfectivo.flujoNeto)} +
+
+
+
+ + + + Flujo de Efectivo Mensual + + + ({ + mes: e.concepto, + ingresos: e.monto, + egresos: flujoEfectivo.salidas[i]?.monto || 0, + }))} + /> + + + + ) : null} +
+ + + {loadingComp ? ( +
Cargando...
+ ) : comparativo ? ( + <> +
+ + + Var. Ingresos vs Ano Anterior + + +
= 0 ? 'text-success' : 'text-destructive'}`}> + {comparativo.variacionIngresos >= 0 ? '+' : ''}{comparativo.variacionIngresos.toFixed(1)}% +
+
+
+ + + Var. Egresos vs Ano Anterior + + +
+ {comparativo.variacionEgresos >= 0 ? '+' : ''}{comparativo.variacionEgresos.toFixed(1)}% +
+
+
+ + + Ano Actual + + +
{año}
+
+
+
+ + + + Comparativo Mensual {año} + + + ({ + mes, + ingresos: comparativo.ingresos[i], + egresos: comparativo.egresos[i], + }))} + /> + + + + ) : null} +
+ + +
+ + + + + Clientes + + + +
+ {clientes?.slice(0, 10).map((c, i) => ( +
+
+
{c.nombre}
+
{c.rfc} - {c.cantidadCfdis} CFDIs
+
+ {formatCurrency(c.totalFacturado)} +
+ ))} +
+
+
+ + + + + Proveedores + + + +
+ {proveedores?.slice(0, 10).map((p, i) => ( +
+
+
{p.nombre}
+
{p.rfc} - {p.cantidadCfdis} CFDIs
+
+ {formatCurrency(p.totalFacturado)} +
+ ))} +
+
+
+
+
+
+
+ ); +} diff --git a/apps/web/app/(dashboard)/usuarios/page.tsx b/apps/web/app/(dashboard)/usuarios/page.tsx new file mode 100644 index 0000000..88a93b0 --- /dev/null +++ b/apps/web/app/(dashboard)/usuarios/page.tsx @@ -0,0 +1,200 @@ +'use client'; + +import { useState } from 'react'; +import { DashboardShell } from '@/components/layouts/dashboard-shell'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { useUsuarios, useInviteUsuario, useUpdateUsuario, useDeleteUsuario } from '@/lib/hooks/use-usuarios'; +import { useAuthStore } from '@/stores/auth-store'; +import { Users, UserPlus, Trash2, Shield, Eye, Calculator } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +const roleLabels = { + admin: { label: 'Administrador', icon: Shield, color: 'text-primary' }, + contador: { label: 'Contador', icon: Calculator, color: 'text-success' }, + visor: { label: 'Visor', icon: Eye, color: 'text-muted-foreground' }, +}; + +export default function UsuariosPage() { + const { user: currentUser } = useAuthStore(); + const { data: usuarios, isLoading } = useUsuarios(); + const inviteUsuario = useInviteUsuario(); + const updateUsuario = useUpdateUsuario(); + const deleteUsuario = useDeleteUsuario(); + + const [showInvite, setShowInvite] = useState(false); + const [inviteForm, setInviteForm] = useState({ email: '', nombre: '', role: 'visor' as const }); + + const handleInvite = async (e: React.FormEvent) => { + e.preventDefault(); + try { + await inviteUsuario.mutateAsync(inviteForm); + setShowInvite(false); + setInviteForm({ email: '', nombre: '', role: 'visor' }); + } catch (error: any) { + alert(error.response?.data?.message || 'Error al invitar usuario'); + } + }; + + const handleToggleActive = (id: string, active: boolean) => { + updateUsuario.mutate({ id, data: { active: !active } }); + }; + + const handleDelete = (id: string) => { + if (confirm('¿Eliminar este usuario?')) { + deleteUsuario.mutate(id); + } + }; + + const isAdmin = currentUser?.role === 'admin'; + + return ( + +
+ {/* Header */} +
+
+ + {usuarios?.length || 0} usuarios +
+ {isAdmin && ( + + )} +
+ + {/* Invite Form */} + {showInvite && ( + + + Invitar Nuevo Usuario + + +
+
+
+ + setInviteForm({ ...inviteForm, email: e.target.value })} + required + /> +
+
+ + setInviteForm({ ...inviteForm, nombre: e.target.value })} + required + /> +
+
+ + +
+
+
+ + +
+
+
+
+ )} + + {/* Users List */} + + + {isLoading ? ( +
Cargando...
+ ) : ( +
+ {usuarios?.map(usuario => { + const roleInfo = roleLabels[usuario.role]; + const RoleIcon = roleInfo.icon; + const isCurrentUser = usuario.id === currentUser?.id; + + return ( +
+
+
+ {usuario.nombre.charAt(0).toUpperCase()} +
+
+
+ {usuario.nombre} + {isCurrentUser && ( + + )} + {!usuario.active && ( + Inactivo + )} +
+
{usuario.email}
+
+
+
+
+ + {roleInfo.label} +
+ {isAdmin && !isCurrentUser && ( +
+ + +
+ )} +
+
+ ); + })} +
+ )} +
+
+
+
+ ); +} diff --git a/apps/web/lib/api/alertas.ts b/apps/web/lib/api/alertas.ts new file mode 100644 index 0000000..ab87260 --- /dev/null +++ b/apps/web/lib/api/alertas.ts @@ -0,0 +1,33 @@ +import { apiClient } from './client'; +import type { AlertaFull, AlertaCreate, AlertaUpdate, AlertasStats } from '@horux/shared'; + +export async function getAlertas(filters?: { leida?: boolean; resuelta?: boolean }): Promise { + const params = new URLSearchParams(); + if (filters?.leida !== undefined) params.set('leida', String(filters.leida)); + if (filters?.resuelta !== undefined) params.set('resuelta', String(filters.resuelta)); + const response = await apiClient.get(`/alertas?${params}`); + return response.data; +} + +export async function getStats(): Promise { + const response = await apiClient.get('/alertas/stats'); + return response.data; +} + +export async function createAlerta(data: AlertaCreate): Promise { + const response = await apiClient.post('/alertas', data); + return response.data; +} + +export async function updateAlerta(id: number, data: AlertaUpdate): Promise { + const response = await apiClient.patch(`/alertas/${id}`, data); + return response.data; +} + +export async function deleteAlerta(id: number): Promise { + await apiClient.delete(`/alertas/${id}`); +} + +export async function markAllAsRead(): Promise { + await apiClient.post('/alertas/mark-all-read'); +} diff --git a/apps/web/lib/api/calendario.ts b/apps/web/lib/api/calendario.ts new file mode 100644 index 0000000..ba4f10e --- /dev/null +++ b/apps/web/lib/api/calendario.ts @@ -0,0 +1,28 @@ +import { apiClient } from './client'; +import type { EventoFiscal, EventoCreate, EventoUpdate } from '@horux/shared'; + +export async function getEventos(año: number, mes?: number): Promise { + const params = new URLSearchParams({ año: año.toString() }); + if (mes) params.set('mes', mes.toString()); + const response = await apiClient.get(`/calendario?${params}`); + return response.data; +} + +export async function getProximos(dias = 30): Promise { + const response = await apiClient.get(`/calendario/proximos?dias=${dias}`); + return response.data; +} + +export async function createEvento(data: EventoCreate): Promise { + const response = await apiClient.post('/calendario', data); + return response.data; +} + +export async function updateEvento(id: number, data: EventoUpdate): Promise { + const response = await apiClient.patch(`/calendario/${id}`, data); + return response.data; +} + +export async function deleteEvento(id: number): Promise { + await apiClient.delete(`/calendario/${id}`); +} diff --git a/apps/web/lib/api/reportes.ts b/apps/web/lib/api/reportes.ts new file mode 100644 index 0000000..6404dc6 --- /dev/null +++ b/apps/web/lib/api/reportes.ts @@ -0,0 +1,36 @@ +import { apiClient } from './client'; +import type { EstadoResultados, FlujoEfectivo, ComparativoPeriodos, ConcentradoRfc } from '@horux/shared'; + +export async function getEstadoResultados(fechaInicio?: string, fechaFin?: string): Promise { + const params = new URLSearchParams(); + if (fechaInicio) params.set('fechaInicio', fechaInicio); + if (fechaFin) params.set('fechaFin', fechaFin); + const response = await apiClient.get(`/reportes/estado-resultados?${params}`); + return response.data; +} + +export async function getFlujoEfectivo(fechaInicio?: string, fechaFin?: string): Promise { + const params = new URLSearchParams(); + if (fechaInicio) params.set('fechaInicio', fechaInicio); + if (fechaFin) params.set('fechaFin', fechaFin); + const response = await apiClient.get(`/reportes/flujo-efectivo?${params}`); + return response.data; +} + +export async function getComparativo(año?: number): Promise { + const params = año ? `?año=${año}` : ''; + const response = await apiClient.get(`/reportes/comparativo${params}`); + return response.data; +} + +export async function getConcentradoRfc( + tipo: 'cliente' | 'proveedor', + fechaInicio?: string, + fechaFin?: string +): Promise { + const params = new URLSearchParams({ tipo }); + if (fechaInicio) params.set('fechaInicio', fechaInicio); + if (fechaFin) params.set('fechaFin', fechaFin); + const response = await apiClient.get(`/reportes/concentrado-rfc?${params}`); + return response.data; +} diff --git a/apps/web/lib/api/usuarios.ts b/apps/web/lib/api/usuarios.ts new file mode 100644 index 0000000..2c57985 --- /dev/null +++ b/apps/web/lib/api/usuarios.ts @@ -0,0 +1,21 @@ +import { apiClient } from './client'; +import type { UserListItem, UserInvite, UserUpdate } from '@horux/shared'; + +export async function getUsuarios(): Promise { + const response = await apiClient.get('/usuarios'); + return response.data; +} + +export async function inviteUsuario(data: UserInvite): Promise { + const response = await apiClient.post('/usuarios/invite', data); + return response.data; +} + +export async function updateUsuario(id: string, data: UserUpdate): Promise { + const response = await apiClient.patch(`/usuarios/${id}`, data); + return response.data; +} + +export async function deleteUsuario(id: string): Promise { + await apiClient.delete(`/usuarios/${id}`); +} diff --git a/apps/web/lib/hooks/use-alertas.ts b/apps/web/lib/hooks/use-alertas.ts new file mode 100644 index 0000000..5498e5a --- /dev/null +++ b/apps/web/lib/hooks/use-alertas.ts @@ -0,0 +1,61 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import * as alertasApi from '../api/alertas'; +import type { AlertaCreate, AlertaUpdate } from '@horux/shared'; + +export function useAlertas(filters?: { leida?: boolean; resuelta?: boolean }) { + return useQuery({ + queryKey: ['alertas', filters], + queryFn: () => alertasApi.getAlertas(filters), + }); +} + +export function useAlertasStats() { + return useQuery({ + queryKey: ['alertas-stats'], + queryFn: alertasApi.getStats, + }); +} + +export function useCreateAlerta() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: AlertaCreate) => alertasApi.createAlerta(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['alertas'] }); + queryClient.invalidateQueries({ queryKey: ['alertas-stats'] }); + }, + }); +} + +export function useUpdateAlerta() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: number; data: AlertaUpdate }) => alertasApi.updateAlerta(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['alertas'] }); + queryClient.invalidateQueries({ queryKey: ['alertas-stats'] }); + }, + }); +} + +export function useDeleteAlerta() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => alertasApi.deleteAlerta(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['alertas'] }); + queryClient.invalidateQueries({ queryKey: ['alertas-stats'] }); + }, + }); +} + +export function useMarkAllAsRead() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: alertasApi.markAllAsRead, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['alertas'] }); + queryClient.invalidateQueries({ queryKey: ['alertas-stats'] }); + }, + }); +} diff --git a/apps/web/lib/hooks/use-calendario.ts b/apps/web/lib/hooks/use-calendario.ts new file mode 100644 index 0000000..962b1ae --- /dev/null +++ b/apps/web/lib/hooks/use-calendario.ts @@ -0,0 +1,47 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import * as calendarioApi from '../api/calendario'; +import type { EventoCreate, EventoUpdate } from '@horux/shared'; + +export function useEventos(año: number, mes?: number) { + return useQuery({ + queryKey: ['calendario', año, mes], + queryFn: () => calendarioApi.getEventos(año, mes), + }); +} + +export function useProximosEventos(dias = 30) { + return useQuery({ + queryKey: ['calendario-proximos', dias], + queryFn: () => calendarioApi.getProximos(dias), + }); +} + +export function useCreateEvento() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: EventoCreate) => calendarioApi.createEvento(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['calendario'] }); + }, + }); +} + +export function useUpdateEvento() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: number; data: EventoUpdate }) => calendarioApi.updateEvento(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['calendario'] }); + }, + }); +} + +export function useDeleteEvento() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => calendarioApi.deleteEvento(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['calendario'] }); + }, + }); +} diff --git a/apps/web/lib/hooks/use-reportes.ts b/apps/web/lib/hooks/use-reportes.ts new file mode 100644 index 0000000..78e8ac6 --- /dev/null +++ b/apps/web/lib/hooks/use-reportes.ts @@ -0,0 +1,30 @@ +import { useQuery } from '@tanstack/react-query'; +import * as reportesApi from '../api/reportes'; + +export function useEstadoResultados(fechaInicio?: string, fechaFin?: string) { + return useQuery({ + queryKey: ['estado-resultados', fechaInicio, fechaFin], + queryFn: () => reportesApi.getEstadoResultados(fechaInicio, fechaFin), + }); +} + +export function useFlujoEfectivo(fechaInicio?: string, fechaFin?: string) { + return useQuery({ + queryKey: ['flujo-efectivo', fechaInicio, fechaFin], + queryFn: () => reportesApi.getFlujoEfectivo(fechaInicio, fechaFin), + }); +} + +export function useComparativo(año?: number) { + return useQuery({ + queryKey: ['comparativo', año], + queryFn: () => reportesApi.getComparativo(año), + }); +} + +export function useConcentradoRfc(tipo: 'cliente' | 'proveedor', fechaInicio?: string, fechaFin?: string) { + return useQuery({ + queryKey: ['concentrado-rfc', tipo, fechaInicio, fechaFin], + queryFn: () => reportesApi.getConcentradoRfc(tipo, fechaInicio, fechaFin), + }); +} diff --git a/apps/web/lib/hooks/use-usuarios.ts b/apps/web/lib/hooks/use-usuarios.ts new file mode 100644 index 0000000..31721dc --- /dev/null +++ b/apps/web/lib/hooks/use-usuarios.ts @@ -0,0 +1,40 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import * as usuariosApi from '../api/usuarios'; +import type { UserInvite, UserUpdate } from '@horux/shared'; + +export function useUsuarios() { + return useQuery({ + queryKey: ['usuarios'], + queryFn: usuariosApi.getUsuarios, + }); +} + +export function useInviteUsuario() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: UserInvite) => usuariosApi.inviteUsuario(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['usuarios'] }); + }, + }); +} + +export function useUpdateUsuario() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: UserUpdate }) => usuariosApi.updateUsuario(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['usuarios'] }); + }, + }); +} + +export function useDeleteUsuario() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => usuariosApi.deleteUsuario(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['usuarios'] }); + }, + }); +} diff --git a/docs/plans/2026-01-22-fase3-implementation.md b/docs/plans/2026-01-22-fase3-implementation.md new file mode 100644 index 0000000..a5b7246 --- /dev/null +++ b/docs/plans/2026-01-22-fase3-implementation.md @@ -0,0 +1,2755 @@ +# Fase 3: Funcionalidades Avanzadas - Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:dispatching-parallel-agents to implement modules in parallel. + +**Goal:** Implementar reportes, exportación Excel/PDF, sistema de alertas, calendario fiscal y gestión de usuarios. + +**Architecture:** 5 módulos independientes que pueden desarrollarse en paralelo. Cada módulo tiene backend (service/controller/routes) y frontend (page/hooks/api). + +**Tech Stack:** Express + Prisma (backend), Next.js 14 + React Query (frontend), xlsx + @react-pdf/renderer (exportación) + +--- + +## Módulos Paralelos + +| Módulo | Descripción | Dependencias | +|--------|-------------|--------------| +| **A: Reportes** | Estado de resultados, flujo efectivo, comparativos | Ninguna | +| **B: Exportación** | Excel y PDF para CFDIs y reportes | Ninguna | +| **C: Alertas** | CRUD completo, marcar leída/resuelta | Ninguna | +| **D: Calendario** | Obligaciones fiscales, eventos, recordatorios | Ninguna | +| **E: Usuarios** | Invitar, roles, permisos, auditoría | Ninguna | + +--- + +## Módulo A: Reportes + +### A1: Tipos Compartidos para Reportes + +**Files:** +- Create: `packages/shared/src/types/reportes.ts` +- Modify: `packages/shared/src/index.ts` + +**Code:** + +```typescript +// packages/shared/src/types/reportes.ts +export interface EstadoResultados { + periodo: { inicio: string; fin: string }; + ingresos: { concepto: string; monto: number }[]; + egresos: { concepto: string; monto: number }[]; + totalIngresos: number; + totalEgresos: number; + utilidadBruta: number; + impuestos: number; + utilidadNeta: number; +} + +export interface FlujoEfectivo { + periodo: { inicio: string; fin: string }; + saldoInicial: number; + entradas: { concepto: string; monto: number }[]; + salidas: { concepto: string; monto: number }[]; + totalEntradas: number; + totalSalidas: number; + flujoNeto: number; + saldoFinal: number; +} + +export interface ComparativoPeriodos { + periodos: string[]; + ingresos: number[]; + egresos: number[]; + utilidad: number[]; + variacionIngresos: number; + variacionEgresos: number; + variacionUtilidad: number; +} + +export interface ConcentradoRfc { + rfc: string; + nombre: string; + tipo: 'cliente' | 'proveedor'; + totalFacturado: number; + totalIva: number; + cantidadCfdis: number; +} + +export interface ReporteFilters { + fechaInicio: string; + fechaFin: string; + tipo?: 'mensual' | 'trimestral' | 'anual'; +} +``` + +**Add to index.ts:** +```typescript +export * from './types/reportes'; +``` + +--- + +### A2: API de Reportes (Backend) + +**Files:** +- Create: `apps/api/src/services/reportes.service.ts` +- Create: `apps/api/src/controllers/reportes.controller.ts` +- Create: `apps/api/src/routes/reportes.routes.ts` +- Modify: `apps/api/src/app.ts` + +**Service (reportes.service.ts):** + +```typescript +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 { + 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 { + 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 { + 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 { + if (tipo === 'cliente') { + const data = await prisma.$queryRawUnsafe(` + 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(` + 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) })); + } +} +``` + +**Controller (reportes.controller.ts):** + +```typescript +import { Request, Response, NextFunction } from 'express'; +import * as reportesService from '../services/reportes.service.js'; + +export async function getEstadoResultados(req: Request, res: Response, next: NextFunction) { + try { + const { fechaInicio, fechaFin } = req.query; + const now = new Date(); + const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`; + const fin = (fechaFin as string) || now.toISOString().split('T')[0]; + + const data = await reportesService.getEstadoResultados(req.tenantSchema!, inicio, fin); + res.json(data); + } catch (error) { + next(error); + } +} + +export async function getFlujoEfectivo(req: Request, res: Response, next: NextFunction) { + try { + const { fechaInicio, fechaFin } = req.query; + const now = new Date(); + const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`; + const fin = (fechaFin as string) || now.toISOString().split('T')[0]; + + const data = await reportesService.getFlujoEfectivo(req.tenantSchema!, inicio, fin); + res.json(data); + } catch (error) { + next(error); + } +} + +export async function getComparativo(req: Request, res: Response, next: NextFunction) { + try { + const año = parseInt(req.query.año as string) || new Date().getFullYear(); + const data = await reportesService.getComparativo(req.tenantSchema!, año); + res.json(data); + } catch (error) { + next(error); + } +} + +export async function getConcentradoRfc(req: Request, res: Response, next: NextFunction) { + try { + const { fechaInicio, fechaFin, tipo } = req.query; + const now = new Date(); + const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`; + const fin = (fechaFin as string) || now.toISOString().split('T')[0]; + const tipoRfc = (tipo as 'cliente' | 'proveedor') || 'cliente'; + + const data = await reportesService.getConcentradoRfc(req.tenantSchema!, inicio, fin, tipoRfc); + res.json(data); + } catch (error) { + next(error); + } +} +``` + +**Routes (reportes.routes.ts):** + +```typescript +import { Router } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import * as reportesController from '../controllers/reportes.controller.js'; + +const router = Router(); + +router.use(authenticate); +router.use(tenantMiddleware); + +router.get('/estado-resultados', reportesController.getEstadoResultados); +router.get('/flujo-efectivo', reportesController.getFlujoEfectivo); +router.get('/comparativo', reportesController.getComparativo); +router.get('/concentrado-rfc', reportesController.getConcentradoRfc); + +export { router as reportesRoutes }; +``` + +**Add to app.ts:** +```typescript +import { reportesRoutes } from './routes/reportes.routes.js'; +// ... +app.use('/api/reportes', reportesRoutes); +``` + +--- + +### A3: Frontend de Reportes + +**Files:** +- Create: `apps/web/lib/api/reportes.ts` +- Create: `apps/web/lib/hooks/use-reportes.ts` +- Create: `apps/web/app/(dashboard)/reportes/page.tsx` + +**API Client (reportes.ts):** + +```typescript +import { apiClient } from './client'; +import type { EstadoResultados, FlujoEfectivo, ComparativoPeriodos, ConcentradoRfc } from '@horux/shared'; + +export async function getEstadoResultados(fechaInicio?: string, fechaFin?: string): Promise { + const params = new URLSearchParams(); + if (fechaInicio) params.set('fechaInicio', fechaInicio); + if (fechaFin) params.set('fechaFin', fechaFin); + const response = await apiClient.get(`/reportes/estado-resultados?${params}`); + return response.data; +} + +export async function getFlujoEfectivo(fechaInicio?: string, fechaFin?: string): Promise { + const params = new URLSearchParams(); + if (fechaInicio) params.set('fechaInicio', fechaInicio); + if (fechaFin) params.set('fechaFin', fechaFin); + const response = await apiClient.get(`/reportes/flujo-efectivo?${params}`); + return response.data; +} + +export async function getComparativo(año?: number): Promise { + const params = año ? `?año=${año}` : ''; + const response = await apiClient.get(`/reportes/comparativo${params}`); + return response.data; +} + +export async function getConcentradoRfc( + tipo: 'cliente' | 'proveedor', + fechaInicio?: string, + fechaFin?: string +): Promise { + const params = new URLSearchParams({ tipo }); + if (fechaInicio) params.set('fechaInicio', fechaInicio); + if (fechaFin) params.set('fechaFin', fechaFin); + const response = await apiClient.get(`/reportes/concentrado-rfc?${params}`); + return response.data; +} +``` + +**Hooks (use-reportes.ts):** + +```typescript +import { useQuery } from '@tanstack/react-query'; +import * as reportesApi from '../api/reportes'; + +export function useEstadoResultados(fechaInicio?: string, fechaFin?: string) { + return useQuery({ + queryKey: ['estado-resultados', fechaInicio, fechaFin], + queryFn: () => reportesApi.getEstadoResultados(fechaInicio, fechaFin), + }); +} + +export function useFlujoEfectivo(fechaInicio?: string, fechaFin?: string) { + return useQuery({ + queryKey: ['flujo-efectivo', fechaInicio, fechaFin], + queryFn: () => reportesApi.getFlujoEfectivo(fechaInicio, fechaFin), + }); +} + +export function useComparativo(año?: number) { + return useQuery({ + queryKey: ['comparativo', año], + queryFn: () => reportesApi.getComparativo(año), + }); +} + +export function useConcentradoRfc(tipo: 'cliente' | 'proveedor', fechaInicio?: string, fechaFin?: string) { + return useQuery({ + queryKey: ['concentrado-rfc', tipo, fechaInicio, fechaFin], + queryFn: () => reportesApi.getConcentradoRfc(tipo, fechaInicio, fechaFin), + }); +} +``` + +**Page (reportes/page.tsx):** + +```tsx +'use client'; + +import { useState } from 'react'; +import { DashboardShell } from '@/components/layouts/dashboard-shell'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { useEstadoResultados, useFlujoEfectivo, useComparativo, useConcentradoRfc } from '@/lib/hooks/use-reportes'; +import { BarChart } from '@/components/charts/bar-chart'; +import { formatCurrency } from '@/lib/utils'; +import { FileText, TrendingUp, TrendingDown, Users } from 'lucide-react'; + +export default function ReportesPage() { + const [año] = useState(new Date().getFullYear()); + const fechaInicio = `${año}-01-01`; + const fechaFin = `${año}-12-31`; + + const { data: estadoResultados, isLoading: loadingER } = useEstadoResultados(fechaInicio, fechaFin); + const { data: flujoEfectivo, isLoading: loadingFE } = useFlujoEfectivo(fechaInicio, fechaFin); + const { data: comparativo, isLoading: loadingComp } = useComparativo(año); + const { data: clientes } = useConcentradoRfc('cliente', fechaInicio, fechaFin); + const { data: proveedores } = useConcentradoRfc('proveedor', fechaInicio, fechaFin); + + return ( + + + + Estado de Resultados + Flujo de Efectivo + Comparativo + Concentrado RFC + + + + {loadingER ? ( +
Cargando...
+ ) : estadoResultados ? ( + <> +
+ + + Total Ingresos + + + +
+ {formatCurrency(estadoResultados.totalIngresos)} +
+
+
+ + + Total Egresos + + + +
+ {formatCurrency(estadoResultados.totalEgresos)} +
+
+
+ + + Utilidad Bruta + + + +
= 0 ? 'text-success' : 'text-destructive'}`}> + {formatCurrency(estadoResultados.utilidadBruta)} +
+
+
+ + + Utilidad Neta + + + +
= 0 ? 'text-success' : 'text-destructive'}`}> + {formatCurrency(estadoResultados.utilidadNeta)} +
+
+
+
+ +
+ + + Top 10 Ingresos por Cliente + + +
+ {estadoResultados.ingresos.map((item, i) => ( +
+ {item.concepto} + {formatCurrency(item.monto)} +
+ ))} +
+
+
+ + + Top 10 Egresos por Proveedor + + +
+ {estadoResultados.egresos.map((item, i) => ( +
+ {item.concepto} + {formatCurrency(item.monto)} +
+ ))} +
+
+
+
+ + ) : null} +
+ + + {loadingFE ? ( +
Cargando...
+ ) : flujoEfectivo ? ( + <> +
+ + + Total Entradas + + +
+ {formatCurrency(flujoEfectivo.totalEntradas)} +
+
+
+ + + Total Salidas + + +
+ {formatCurrency(flujoEfectivo.totalSalidas)} +
+
+
+ + + Flujo Neto + + +
= 0 ? 'text-success' : 'text-destructive'}`}> + {formatCurrency(flujoEfectivo.flujoNeto)} +
+
+
+
+ + + + Flujo de Efectivo Mensual + + + ({ + mes: e.concepto, + ingresos: e.monto, + egresos: flujoEfectivo.salidas[i]?.monto || 0, + }))} + /> + + + + ) : null} +
+ + + {loadingComp ? ( +
Cargando...
+ ) : comparativo ? ( + <> +
+ + + Var. Ingresos vs Año Anterior + + +
= 0 ? 'text-success' : 'text-destructive'}`}> + {comparativo.variacionIngresos >= 0 ? '+' : ''}{comparativo.variacionIngresos.toFixed(1)}% +
+
+
+ + + Var. Egresos vs Año Anterior + + +
+ {comparativo.variacionEgresos >= 0 ? '+' : ''}{comparativo.variacionEgresos.toFixed(1)}% +
+
+
+ + + Año Actual + + +
{año}
+
+
+
+ + + + Comparativo Mensual {año} + + + ({ + mes, + ingresos: comparativo.ingresos[i], + egresos: comparativo.egresos[i], + }))} + /> + + + + ) : null} +
+ + +
+ + + + + Clientes + + + +
+ {clientes?.slice(0, 10).map((c, i) => ( +
+
+
{c.nombre}
+
{c.rfc} · {c.cantidadCfdis} CFDIs
+
+ {formatCurrency(c.totalFacturado)} +
+ ))} +
+
+
+ + + + + Proveedores + + + +
+ {proveedores?.slice(0, 10).map((p, i) => ( +
+
+
{p.nombre}
+
{p.rfc} · {p.cantidadCfdis} CFDIs
+
+ {formatCurrency(p.totalFacturado)} +
+ ))} +
+
+
+
+
+
+
+ ); +} +``` + +**Commit:** `git commit -m "feat(reportes): add reports module with estado resultados, flujo efectivo, comparativo"` + +--- + +## Módulo B: Exportación Excel/PDF + +### B1: Dependencias de Exportación + +**Files:** +- Modify: `apps/api/package.json` +- Modify: `apps/web/package.json` + +**API package.json - add:** +```json +"exceljs": "^4.4.0" +``` + +**Web package.json - add:** +```json +"@react-pdf/renderer": "^3.4.0" +``` + +--- + +### B2: API de Exportación (Backend) + +**Files:** +- Create: `apps/api/src/services/export.service.ts` +- Create: `apps/api/src/controllers/export.controller.ts` +- Create: `apps/api/src/routes/export.routes.ts` +- Modify: `apps/api/src/app.ts` + +**Service (export.service.ts):** + +```typescript +import ExcelJS from 'exceljs'; +import { prisma } from '../config/database.js'; + +export async function exportCfdisToExcel( + schema: string, + filters: { tipo?: string; estado?: string; fechaInicio?: string; fechaFin?: string } +): Promise { + let whereClause = 'WHERE 1=1'; + const params: any[] = []; + let paramIndex = 1; + + if (filters.tipo) { + whereClause += ` AND tipo = $${paramIndex++}`; + params.push(filters.tipo); + } + if (filters.estado) { + whereClause += ` AND estado = $${paramIndex++}`; + params.push(filters.estado); + } + if (filters.fechaInicio) { + whereClause += ` AND fecha_emision >= $${paramIndex++}`; + params.push(filters.fechaInicio); + } + if (filters.fechaFin) { + whereClause += ` AND fecha_emision <= $${paramIndex++}`; + params.push(filters.fechaFin); + } + + const cfdis = await prisma.$queryRawUnsafe(` + SELECT uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado, + rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor, + subtotal, descuento, iva, isr_retenido, iva_retenido, total, + moneda, metodo_pago, forma_pago, uso_cfdi, estado + FROM "${schema}".cfdis + ${whereClause} + ORDER BY fecha_emision DESC + `, ...params); + + const workbook = new ExcelJS.Workbook(); + const sheet = workbook.addWorksheet('CFDIs'); + + sheet.columns = [ + { header: 'UUID', key: 'uuid_fiscal', width: 40 }, + { header: 'Tipo', key: 'tipo', width: 10 }, + { header: 'Serie', key: 'serie', width: 10 }, + { header: 'Folio', key: 'folio', width: 10 }, + { header: 'Fecha Emisión', key: 'fecha_emision', width: 15 }, + { header: 'RFC Emisor', key: 'rfc_emisor', width: 15 }, + { header: 'Nombre Emisor', key: 'nombre_emisor', width: 30 }, + { header: 'RFC Receptor', key: 'rfc_receptor', width: 15 }, + { header: 'Nombre Receptor', key: 'nombre_receptor', width: 30 }, + { header: 'Subtotal', key: 'subtotal', width: 15 }, + { header: 'IVA', key: 'iva', width: 15 }, + { header: 'Total', key: 'total', width: 15 }, + { header: 'Estado', key: 'estado', width: 12 }, + ]; + + sheet.getRow(1).font = { bold: true }; + sheet.getRow(1).fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FF4472C4' }, + }; + sheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } }; + + cfdis.forEach(cfdi => { + sheet.addRow({ + ...cfdi, + fecha_emision: new Date(cfdi.fecha_emision).toLocaleDateString('es-MX'), + subtotal: Number(cfdi.subtotal), + iva: Number(cfdi.iva), + total: Number(cfdi.total), + }); + }); + + const buffer = await workbook.xlsx.writeBuffer(); + return Buffer.from(buffer); +} + +export async function exportReporteToExcel( + schema: string, + tipo: 'estado-resultados' | 'flujo-efectivo', + fechaInicio: string, + fechaFin: string +): Promise { + const workbook = new ExcelJS.Workbook(); + const sheet = workbook.addWorksheet(tipo === 'estado-resultados' ? 'Estado de Resultados' : 'Flujo de Efectivo'); + + if (tipo === 'estado-resultados') { + const [totales] = await prisma.$queryRawUnsafe<[{ ingresos: number; egresos: 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 + FROM "${schema}".cfdis + WHERE estado = 'vigente' AND fecha_emision BETWEEN $1 AND $2 + `, fechaInicio, fechaFin); + + sheet.columns = [ + { header: 'Concepto', key: 'concepto', width: 40 }, + { header: 'Monto', key: 'monto', width: 20 }, + ]; + + sheet.addRow({ concepto: 'INGRESOS', monto: '' }); + sheet.addRow({ concepto: 'Total Ingresos', monto: Number(totales?.ingresos || 0) }); + sheet.addRow({ concepto: '', monto: '' }); + sheet.addRow({ concepto: 'EGRESOS', monto: '' }); + sheet.addRow({ concepto: 'Total Egresos', monto: Number(totales?.egresos || 0) }); + sheet.addRow({ concepto: '', monto: '' }); + sheet.addRow({ concepto: 'UTILIDAD NETA', monto: Number(totales?.ingresos || 0) - Number(totales?.egresos || 0) }); + } + + const buffer = await workbook.xlsx.writeBuffer(); + return Buffer.from(buffer); +} +``` + +**Controller (export.controller.ts):** + +```typescript +import { Request, Response, NextFunction } from 'express'; +import * as exportService from '../services/export.service.js'; + +export async function exportCfdis(req: Request, res: Response, next: NextFunction) { + try { + const { tipo, estado, fechaInicio, fechaFin } = req.query; + const buffer = await exportService.exportCfdisToExcel(req.tenantSchema!, { + tipo: tipo as string, + estado: estado as string, + fechaInicio: fechaInicio as string, + fechaFin: fechaFin as string, + }); + + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.setHeader('Content-Disposition', `attachment; filename=cfdis-${Date.now()}.xlsx`); + res.send(buffer); + } catch (error) { + next(error); + } +} + +export async function exportReporte(req: Request, res: Response, next: NextFunction) { + try { + const { tipo, fechaInicio, fechaFin } = req.query; + const now = new Date(); + const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`; + const fin = (fechaFin as string) || now.toISOString().split('T')[0]; + + const buffer = await exportService.exportReporteToExcel( + req.tenantSchema!, + tipo as 'estado-resultados' | 'flujo-efectivo', + inicio, + fin + ); + + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.setHeader('Content-Disposition', `attachment; filename=${tipo}-${Date.now()}.xlsx`); + res.send(buffer); + } catch (error) { + next(error); + } +} +``` + +**Routes (export.routes.ts):** + +```typescript +import { Router } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import * as exportController from '../controllers/export.controller.js'; + +const router = Router(); + +router.use(authenticate); +router.use(tenantMiddleware); + +router.get('/cfdis', exportController.exportCfdis); +router.get('/reporte', exportController.exportReporte); + +export { router as exportRoutes }; +``` + +**Add to app.ts:** +```typescript +import { exportRoutes } from './routes/export.routes.js'; +// ... +app.use('/api/export', exportRoutes); +``` + +**Commit:** `git commit -m "feat(export): add Excel export for CFDIs and reports"` + +--- + +## Módulo C: Sistema de Alertas + +### C1: Tipos de Alertas (ya existe parcialmente en dashboard.ts) + +**Files:** +- Create: `packages/shared/src/types/alertas.ts` +- Modify: `packages/shared/src/index.ts` + +**alertas.ts:** + +```typescript +export type TipoAlerta = 'vencimiento' | 'discrepancia' | 'iva_favor' | 'declaracion' | 'limite_cfdi' | 'custom'; +export type PrioridadAlerta = 'alta' | 'media' | 'baja'; + +export interface AlertaCreate { + tipo: TipoAlerta; + titulo: string; + mensaje: string; + prioridad: PrioridadAlerta; + fechaVencimiento?: string; +} + +export interface AlertaUpdate { + leida?: boolean; + resuelta?: boolean; +} + +export interface AlertaFull { + id: number; + tipo: TipoAlerta; + titulo: string; + mensaje: string; + prioridad: PrioridadAlerta; + fechaVencimiento: string | null; + leida: boolean; + resuelta: boolean; + createdAt: string; +} + +export interface AlertasStats { + total: number; + noLeidas: number; + alta: number; + media: number; + baja: number; +} +``` + +--- + +### C2: API de Alertas (Backend) + +**Files:** +- Create: `apps/api/src/services/alertas.service.ts` +- Create: `apps/api/src/controllers/alertas.controller.ts` +- Create: `apps/api/src/routes/alertas.routes.ts` +- Modify: `apps/api/src/app.ts` + +**Service (alertas.service.ts):** + +```typescript +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 { + 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(` + 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 { + const [alerta] = await prisma.$queryRawUnsafe(` + 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 { + const [alerta] = await prisma.$queryRawUnsafe(` + 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 { + 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(` + 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 { + await prisma.$queryRawUnsafe(`DELETE FROM "${schema}".alertas WHERE id = $1`, id); +} + +export async function getStats(schema: string): Promise { + const [stats] = await prisma.$queryRawUnsafe(` + 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 { + await prisma.$queryRawUnsafe(`UPDATE "${schema}".alertas SET leida = true WHERE leida = false`); +} +``` + +**Controller (alertas.controller.ts):** + +```typescript +import { Request, Response, NextFunction } from 'express'; +import * as alertasService from '../services/alertas.service.js'; + +export async function getAlertas(req: Request, res: Response, next: NextFunction) { + try { + const { leida, resuelta, prioridad } = req.query; + const alertas = await alertasService.getAlertas(req.tenantSchema!, { + leida: leida === 'true' ? true : leida === 'false' ? false : undefined, + resuelta: resuelta === 'true' ? true : resuelta === 'false' ? false : undefined, + prioridad: prioridad as string, + }); + res.json(alertas); + } catch (error) { + next(error); + } +} + +export async function getAlerta(req: Request, res: Response, next: NextFunction) { + try { + const alerta = await alertasService.getAlertaById(req.tenantSchema!, parseInt(req.params.id)); + if (!alerta) { + return res.status(404).json({ message: 'Alerta no encontrada' }); + } + res.json(alerta); + } catch (error) { + next(error); + } +} + +export async function createAlerta(req: Request, res: Response, next: NextFunction) { + try { + const alerta = await alertasService.createAlerta(req.tenantSchema!, req.body); + res.status(201).json(alerta); + } catch (error) { + next(error); + } +} + +export async function updateAlerta(req: Request, res: Response, next: NextFunction) { + try { + const alerta = await alertasService.updateAlerta(req.tenantSchema!, parseInt(req.params.id), req.body); + res.json(alerta); + } catch (error) { + next(error); + } +} + +export async function deleteAlerta(req: Request, res: Response, next: NextFunction) { + try { + await alertasService.deleteAlerta(req.tenantSchema!, parseInt(req.params.id)); + res.status(204).send(); + } catch (error) { + next(error); + } +} + +export async function getStats(req: Request, res: Response, next: NextFunction) { + try { + const stats = await alertasService.getStats(req.tenantSchema!); + res.json(stats); + } catch (error) { + next(error); + } +} + +export async function markAllAsRead(req: Request, res: Response, next: NextFunction) { + try { + await alertasService.markAllAsRead(req.tenantSchema!); + res.json({ success: true }); + } catch (error) { + next(error); + } +} +``` + +**Routes (alertas.routes.ts):** + +```typescript +import { Router } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import * as alertasController from '../controllers/alertas.controller.js'; + +const router = Router(); + +router.use(authenticate); +router.use(tenantMiddleware); + +router.get('/', alertasController.getAlertas); +router.get('/stats', alertasController.getStats); +router.post('/mark-all-read', alertasController.markAllAsRead); +router.get('/:id', alertasController.getAlerta); +router.post('/', alertasController.createAlerta); +router.patch('/:id', alertasController.updateAlerta); +router.delete('/:id', alertasController.deleteAlerta); + +export { router as alertasRoutes }; +``` + +**Add to app.ts:** +```typescript +import { alertasRoutes } from './routes/alertas.routes.js'; +// ... +app.use('/api/alertas', alertasRoutes); +``` + +--- + +### C3: Frontend de Alertas + +**Files:** +- Create: `apps/web/lib/api/alertas.ts` +- Create: `apps/web/lib/hooks/use-alertas.ts` +- Create: `apps/web/app/(dashboard)/alertas/page.tsx` + +**API Client (alertas.ts):** + +```typescript +import { apiClient } from './client'; +import type { AlertaFull, AlertaCreate, AlertaUpdate, AlertasStats } from '@horux/shared'; + +export async function getAlertas(filters?: { leida?: boolean; resuelta?: boolean }): Promise { + const params = new URLSearchParams(); + if (filters?.leida !== undefined) params.set('leida', String(filters.leida)); + if (filters?.resuelta !== undefined) params.set('resuelta', String(filters.resuelta)); + const response = await apiClient.get(`/alertas?${params}`); + return response.data; +} + +export async function getStats(): Promise { + const response = await apiClient.get('/alertas/stats'); + return response.data; +} + +export async function createAlerta(data: AlertaCreate): Promise { + const response = await apiClient.post('/alertas', data); + return response.data; +} + +export async function updateAlerta(id: number, data: AlertaUpdate): Promise { + const response = await apiClient.patch(`/alertas/${id}`, data); + return response.data; +} + +export async function deleteAlerta(id: number): Promise { + await apiClient.delete(`/alertas/${id}`); +} + +export async function markAllAsRead(): Promise { + await apiClient.post('/alertas/mark-all-read'); +} +``` + +**Hooks (use-alertas.ts):** + +```typescript +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import * as alertasApi from '../api/alertas'; +import type { AlertaCreate, AlertaUpdate } from '@horux/shared'; + +export function useAlertas(filters?: { leida?: boolean; resuelta?: boolean }) { + return useQuery({ + queryKey: ['alertas', filters], + queryFn: () => alertasApi.getAlertas(filters), + }); +} + +export function useAlertasStats() { + return useQuery({ + queryKey: ['alertas-stats'], + queryFn: alertasApi.getStats, + }); +} + +export function useCreateAlerta() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: AlertaCreate) => alertasApi.createAlerta(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['alertas'] }); + queryClient.invalidateQueries({ queryKey: ['alertas-stats'] }); + }, + }); +} + +export function useUpdateAlerta() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: number; data: AlertaUpdate }) => alertasApi.updateAlerta(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['alertas'] }); + queryClient.invalidateQueries({ queryKey: ['alertas-stats'] }); + }, + }); +} + +export function useDeleteAlerta() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => alertasApi.deleteAlerta(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['alertas'] }); + queryClient.invalidateQueries({ queryKey: ['alertas-stats'] }); + }, + }); +} + +export function useMarkAllAsRead() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: alertasApi.markAllAsRead, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['alertas'] }); + queryClient.invalidateQueries({ queryKey: ['alertas-stats'] }); + }, + }); +} +``` + +**Page (alertas/page.tsx):** + +```tsx +'use client'; + +import { useState } from 'react'; +import { DashboardShell } from '@/components/layouts/dashboard-shell'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { useAlertas, useAlertasStats, useUpdateAlerta, useDeleteAlerta, useMarkAllAsRead } from '@/lib/hooks/use-alertas'; +import { Bell, Check, Trash2, AlertTriangle, Info, AlertCircle, CheckCircle } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +const prioridadStyles = { + alta: 'border-l-4 border-l-destructive bg-destructive/5', + media: 'border-l-4 border-l-warning bg-warning/5', + baja: 'border-l-4 border-l-muted bg-muted/5', +}; + +const prioridadIcons = { + alta: AlertCircle, + media: AlertTriangle, + baja: Info, +}; + +export default function AlertasPage() { + const [filter, setFilter] = useState<'todas' | 'pendientes' | 'resueltas'>('pendientes'); + const { data: alertas, isLoading } = useAlertas({ + resuelta: filter === 'resueltas' ? true : filter === 'pendientes' ? false : undefined, + }); + const { data: stats } = useAlertasStats(); + const updateAlerta = useUpdateAlerta(); + const deleteAlerta = useDeleteAlerta(); + const markAllAsRead = useMarkAllAsRead(); + + const handleMarkAsRead = (id: number) => { + updateAlerta.mutate({ id, data: { leida: true } }); + }; + + const handleResolve = (id: number) => { + updateAlerta.mutate({ id, data: { resuelta: true } }); + }; + + const handleDelete = (id: number) => { + if (confirm('¿Eliminar esta alerta?')) { + deleteAlerta.mutate(id); + } + }; + + return ( + +
+ {/* Stats */} +
+ + + Total + + + +
{stats?.total || 0}
+
+
+ + + No Leídas + + + +
{stats?.noLeidas || 0}
+
+
+ + + Alta Prioridad + + + +
{stats?.alta || 0}
+
+
+ + + Pendientes + + + +
{(stats?.alta || 0) + (stats?.media || 0) + (stats?.baja || 0)}
+
+
+
+ + {/* Filters */} +
+ + + +
+ +
+ + {/* Alertas List */} +
+ {isLoading ? ( +
Cargando...
+ ) : alertas?.length === 0 ? ( + + + +

No hay alertas {filter === 'pendientes' ? 'pendientes' : ''}

+
+
+ ) : ( + alertas?.map((alerta) => { + const Icon = prioridadIcons[alerta.prioridad]; + return ( + + +
+ +
+
+

{alerta.titulo}

+ {!alerta.leida && ( + + Nueva + + )} +
+

{alerta.mensaje}

+
+ {new Date(alerta.createdAt).toLocaleDateString('es-MX')} + {alerta.fechaVencimiento && ( + Vence: {new Date(alerta.fechaVencimiento).toLocaleDateString('es-MX')} + )} +
+
+
+ {!alerta.leida && ( + + )} + {!alerta.resuelta && ( + + )} + +
+
+
+
+ ); + }) + )} +
+
+ + ); +} +``` + +**Commit:** `git commit -m "feat(alertas): add alerts CRUD with stats and management UI"` + +--- + +## Módulo D: Calendario Fiscal + +### D1: Tipos de Calendario + +**Files:** +- Create: `packages/shared/src/types/calendario.ts` +- Modify: `packages/shared/src/index.ts` + +**calendario.ts:** + +```typescript +export type TipoEvento = 'declaracion' | 'pago' | 'obligacion' | 'custom'; +export type Recurrencia = 'mensual' | 'bimestral' | 'trimestral' | 'anual' | 'unica'; + +export interface EventoFiscal { + id: number; + titulo: string; + descripcion: string; + tipo: TipoEvento; + fechaLimite: string; + recurrencia: Recurrencia; + completado: boolean; + notas: string | null; + createdAt: string; +} + +export interface EventoCreate { + titulo: string; + descripcion: string; + tipo: TipoEvento; + fechaLimite: string; + recurrencia: Recurrencia; + notas?: string; +} + +export interface EventoUpdate { + titulo?: string; + descripcion?: string; + fechaLimite?: string; + completado?: boolean; + notas?: string; +} + +export interface CalendarioMes { + año: number; + mes: number; + eventos: EventoFiscal[]; +} +``` + +--- + +### D2: API de Calendario (Backend) + +**Files:** +- Create: `apps/api/src/services/calendario.service.ts` +- Create: `apps/api/src/controllers/calendario.controller.ts` +- Create: `apps/api/src/routes/calendario.routes.ts` +- Modify: `apps/api/src/app.ts` +- Modify: `apps/api/prisma/seed.ts` (add calendario_fiscal table) + +**Service (calendario.service.ts):** + +```typescript +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 { + 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(` + 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 { + const eventos = await prisma.$queryRawUnsafe(` + 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 { + const [evento] = await prisma.$queryRawUnsafe(` + 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 { + 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(` + 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 { + await prisma.$queryRawUnsafe(`DELETE FROM "${schema}".calendario_fiscal WHERE id = $1`, id); +} +``` + +**Controller (calendario.controller.ts):** + +```typescript +import { Request, Response, NextFunction } from 'express'; +import * as calendarioService from '../services/calendario.service.js'; + +export async function getEventos(req: Request, res: Response, next: NextFunction) { + try { + const { año, mes } = req.query; + const añoNum = parseInt(año as string) || new Date().getFullYear(); + const mesNum = mes ? parseInt(mes as string) : undefined; + + const eventos = await calendarioService.getEventos(req.tenantSchema!, añoNum, mesNum); + res.json(eventos); + } catch (error) { + next(error); + } +} + +export async function getProximos(req: Request, res: Response, next: NextFunction) { + try { + const dias = parseInt(req.query.dias as string) || 30; + const eventos = await calendarioService.getProximosEventos(req.tenantSchema!, dias); + res.json(eventos); + } catch (error) { + next(error); + } +} + +export async function createEvento(req: Request, res: Response, next: NextFunction) { + try { + const evento = await calendarioService.createEvento(req.tenantSchema!, req.body); + res.status(201).json(evento); + } catch (error) { + next(error); + } +} + +export async function updateEvento(req: Request, res: Response, next: NextFunction) { + try { + const evento = await calendarioService.updateEvento(req.tenantSchema!, parseInt(req.params.id), req.body); + res.json(evento); + } catch (error) { + next(error); + } +} + +export async function deleteEvento(req: Request, res: Response, next: NextFunction) { + try { + await calendarioService.deleteEvento(req.tenantSchema!, parseInt(req.params.id)); + res.status(204).send(); + } catch (error) { + next(error); + } +} +``` + +**Routes (calendario.routes.ts):** + +```typescript +import { Router } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import { tenantMiddleware } from '../middlewares/tenant.middleware.js'; +import * as calendarioController from '../controllers/calendario.controller.js'; + +const router = Router(); + +router.use(authenticate); +router.use(tenantMiddleware); + +router.get('/', calendarioController.getEventos); +router.get('/proximos', calendarioController.getProximos); +router.post('/', calendarioController.createEvento); +router.patch('/:id', calendarioController.updateEvento); +router.delete('/:id', calendarioController.deleteEvento); + +export { router as calendarioRoutes }; +``` + +**Add to app.ts:** +```typescript +import { calendarioRoutes } from './routes/calendario.routes.js'; +// ... +app.use('/api/calendario', calendarioRoutes); +``` + +--- + +### D3: Frontend de Calendario + +**Files:** +- Create: `apps/web/lib/api/calendario.ts` +- Create: `apps/web/lib/hooks/use-calendario.ts` +- Create: `apps/web/app/(dashboard)/calendario/page.tsx` + +**API Client (calendario.ts):** + +```typescript +import { apiClient } from './client'; +import type { EventoFiscal, EventoCreate, EventoUpdate } from '@horux/shared'; + +export async function getEventos(año: number, mes?: number): Promise { + const params = new URLSearchParams({ año: año.toString() }); + if (mes) params.set('mes', mes.toString()); + const response = await apiClient.get(`/calendario?${params}`); + return response.data; +} + +export async function getProximos(dias = 30): Promise { + const response = await apiClient.get(`/calendario/proximos?dias=${dias}`); + return response.data; +} + +export async function createEvento(data: EventoCreate): Promise { + const response = await apiClient.post('/calendario', data); + return response.data; +} + +export async function updateEvento(id: number, data: EventoUpdate): Promise { + const response = await apiClient.patch(`/calendario/${id}`, data); + return response.data; +} + +export async function deleteEvento(id: number): Promise { + await apiClient.delete(`/calendario/${id}`); +} +``` + +**Hooks (use-calendario.ts):** + +```typescript +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import * as calendarioApi from '../api/calendario'; +import type { EventoCreate, EventoUpdate } from '@horux/shared'; + +export function useEventos(año: number, mes?: number) { + return useQuery({ + queryKey: ['calendario', año, mes], + queryFn: () => calendarioApi.getEventos(año, mes), + }); +} + +export function useProximosEventos(dias = 30) { + return useQuery({ + queryKey: ['calendario-proximos', dias], + queryFn: () => calendarioApi.getProximos(dias), + }); +} + +export function useCreateEvento() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: EventoCreate) => calendarioApi.createEvento(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['calendario'] }); + }, + }); +} + +export function useUpdateEvento() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: number; data: EventoUpdate }) => calendarioApi.updateEvento(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['calendario'] }); + }, + }); +} + +export function useDeleteEvento() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: number) => calendarioApi.deleteEvento(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['calendario'] }); + }, + }); +} +``` + +**Page (calendario/page.tsx):** + +```tsx +'use client'; + +import { useState } from 'react'; +import { DashboardShell } from '@/components/layouts/dashboard-shell'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { useEventos, useUpdateEvento } from '@/lib/hooks/use-calendario'; +import { Calendar, ChevronLeft, ChevronRight, Check, Clock, FileText, CreditCard } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +const meses = ['Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre']; + +const tipoIcons = { + declaracion: FileText, + pago: CreditCard, + obligacion: Clock, + custom: Calendar, +}; + +const tipoColors = { + declaracion: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200', + pago: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', + obligacion: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200', + custom: 'bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200', +}; + +export default function CalendarioPage() { + const [año, setAño] = useState(new Date().getFullYear()); + const [mes, setMes] = useState(new Date().getMonth() + 1); + const { data: eventos, isLoading } = useEventos(año, mes); + const updateEvento = useUpdateEvento(); + + const handlePrevMonth = () => { + if (mes === 1) { + setMes(12); + setAño(año - 1); + } else { + setMes(mes - 1); + } + }; + + const handleNextMonth = () => { + if (mes === 12) { + setMes(1); + setAño(año + 1); + } else { + setMes(mes + 1); + } + }; + + const handleToggleComplete = (id: number, completado: boolean) => { + updateEvento.mutate({ id, data: { completado: !completado } }); + }; + + // Generate calendar days + const firstDay = new Date(año, mes - 1, 1).getDay(); + const daysInMonth = new Date(año, mes, 0).getDate(); + const days = Array.from({ length: 42 }, (_, i) => { + const day = i - firstDay + 1; + if (day < 1 || day > daysInMonth) return null; + return day; + }); + + const getEventosForDay = (day: number) => { + return eventos?.filter(e => { + const fecha = new Date(e.fechaLimite); + return fecha.getDate() === day; + }) || []; + }; + + return ( + +
+ {/* Calendar */} + + + + + {meses[mes - 1]} {año} + +
+ + +
+
+ +
+ {['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'].map(d => ( +
+ {d} +
+ ))} + {days.map((day, i) => { + const dayEventos = day ? getEventosForDay(day) : []; + const isToday = day === new Date().getDate() && mes === new Date().getMonth() + 1 && año === new Date().getFullYear(); + return ( +
+ {day && ( + <> +
{day}
+
+ {dayEventos.slice(0, 2).map(e => { + const Icon = tipoIcons[e.tipo]; + return ( +
+ + {e.titulo} +
+ ); + })} + {dayEventos.length > 2 && ( +
+{dayEventos.length - 2} más
+ )} +
+ + )} +
+ ); + })} +
+
+
+ + {/* Event List */} + + + Eventos del Mes + + + {isLoading ? ( +
Cargando...
+ ) : eventos?.length === 0 ? ( +
No hay eventos este mes
+ ) : ( +
+ {eventos?.map(evento => { + const Icon = tipoIcons[evento.tipo]; + return ( +
+
+
+
+ +
+
+

+ {evento.titulo} +

+

{evento.descripcion}

+

+ {new Date(evento.fechaLimite).toLocaleDateString('es-MX', { + day: 'numeric', + month: 'short', + })} +

+
+
+ +
+
+ ); + })} +
+ )} +
+
+
+
+ ); +} +``` + +**Commit:** `git commit -m "feat(calendario): add fiscal calendar with events management"` + +--- + +## Módulo E: Gestión de Usuarios + +### E1: Tipos de Usuarios + +**Files:** +- Modify: `packages/shared/src/types/user.ts` + +**Add to user.ts:** + +```typescript +export interface UserInvite { + email: string; + nombre: string; + role: 'admin' | 'contador' | 'visor'; +} + +export interface UserListItem { + id: string; + email: string; + nombre: string; + role: 'admin' | 'contador' | 'visor'; + active: boolean; + lastLogin: string | null; + createdAt: string; +} + +export interface UserUpdate { + nombre?: string; + role?: 'admin' | 'contador' | 'visor'; + active?: boolean; +} + +export interface AuditLog { + id: number; + userId: string; + userName: string; + action: string; + details: string; + ip: string; + createdAt: string; +} +``` + +--- + +### E2: API de Usuarios (Backend) + +**Files:** +- Create: `apps/api/src/services/usuarios.service.ts` +- Create: `apps/api/src/controllers/usuarios.controller.ts` +- Create: `apps/api/src/routes/usuarios.routes.ts` +- Modify: `apps/api/src/app.ts` + +**Service (usuarios.service.ts):** + +```typescript +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 { + 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 { + // 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 { + 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 { + await prisma.user.delete({ + where: { id: userId, tenantId }, + }); +} +``` + +**Controller (usuarios.controller.ts):** + +```typescript +import { Request, Response, NextFunction } from 'express'; +import * as usuariosService from '../services/usuarios.service.js'; +import { AppError } from '../utils/errors.js'; + +export async function getUsuarios(req: Request, res: Response, next: NextFunction) { + try { + const usuarios = await usuariosService.getUsuarios(req.user!.tenantId); + res.json(usuarios); + } catch (error) { + next(error); + } +} + +export async function inviteUsuario(req: Request, res: Response, next: NextFunction) { + try { + if (req.user!.role !== 'admin') { + throw new AppError(403, 'Solo administradores pueden invitar usuarios'); + } + const usuario = await usuariosService.inviteUsuario(req.user!.tenantId, req.body); + res.status(201).json(usuario); + } catch (error) { + next(error); + } +} + +export async function updateUsuario(req: Request, res: Response, next: NextFunction) { + try { + if (req.user!.role !== 'admin') { + throw new AppError(403, 'Solo administradores pueden modificar usuarios'); + } + const usuario = await usuariosService.updateUsuario(req.user!.tenantId, req.params.id, req.body); + res.json(usuario); + } catch (error) { + next(error); + } +} + +export async function deleteUsuario(req: Request, res: Response, next: NextFunction) { + try { + if (req.user!.role !== 'admin') { + throw new AppError(403, 'Solo administradores pueden eliminar usuarios'); + } + if (req.params.id === req.user!.id) { + throw new AppError(400, 'No puedes eliminar tu propia cuenta'); + } + await usuariosService.deleteUsuario(req.user!.tenantId, req.params.id); + res.status(204).send(); + } catch (error) { + next(error); + } +} +``` + +**Routes (usuarios.routes.ts):** + +```typescript +import { Router } from 'express'; +import { authenticate } from '../middlewares/auth.middleware.js'; +import * as usuariosController from '../controllers/usuarios.controller.js'; + +const router = Router(); + +router.use(authenticate); + +router.get('/', usuariosController.getUsuarios); +router.post('/invite', usuariosController.inviteUsuario); +router.patch('/:id', usuariosController.updateUsuario); +router.delete('/:id', usuariosController.deleteUsuario); + +export { router as usuariosRoutes }; +``` + +**Add to app.ts:** +```typescript +import { usuariosRoutes } from './routes/usuarios.routes.js'; +// ... +app.use('/api/usuarios', usuariosRoutes); +``` + +--- + +### E3: Frontend de Usuarios + +**Files:** +- Create: `apps/web/lib/api/usuarios.ts` +- Create: `apps/web/lib/hooks/use-usuarios.ts` +- Create: `apps/web/app/(dashboard)/usuarios/page.tsx` + +**API Client (usuarios.ts):** + +```typescript +import { apiClient } from './client'; +import type { UserListItem, UserInvite, UserUpdate } from '@horux/shared'; + +export async function getUsuarios(): Promise { + const response = await apiClient.get('/usuarios'); + return response.data; +} + +export async function inviteUsuario(data: UserInvite): Promise { + const response = await apiClient.post('/usuarios/invite', data); + return response.data; +} + +export async function updateUsuario(id: string, data: UserUpdate): Promise { + const response = await apiClient.patch(`/usuarios/${id}`, data); + return response.data; +} + +export async function deleteUsuario(id: string): Promise { + await apiClient.delete(`/usuarios/${id}`); +} +``` + +**Hooks (use-usuarios.ts):** + +```typescript +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import * as usuariosApi from '../api/usuarios'; +import type { UserInvite, UserUpdate } from '@horux/shared'; + +export function useUsuarios() { + return useQuery({ + queryKey: ['usuarios'], + queryFn: usuariosApi.getUsuarios, + }); +} + +export function useInviteUsuario() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (data: UserInvite) => usuariosApi.inviteUsuario(data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['usuarios'] }); + }, + }); +} + +export function useUpdateUsuario() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ id, data }: { id: string; data: UserUpdate }) => usuariosApi.updateUsuario(id, data), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['usuarios'] }); + }, + }); +} + +export function useDeleteUsuario() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => usuariosApi.deleteUsuario(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['usuarios'] }); + }, + }); +} +``` + +**Page (usuarios/page.tsx):** + +```tsx +'use client'; + +import { useState } from 'react'; +import { DashboardShell } from '@/components/layouts/dashboard-shell'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { useUsuarios, useInviteUsuario, useUpdateUsuario, useDeleteUsuario } from '@/lib/hooks/use-usuarios'; +import { useAuthStore } from '@/stores/auth-store'; +import { Users, UserPlus, Trash2, Shield, Eye, Calculator } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +const roleLabels = { + admin: { label: 'Administrador', icon: Shield, color: 'text-primary' }, + contador: { label: 'Contador', icon: Calculator, color: 'text-success' }, + visor: { label: 'Visor', icon: Eye, color: 'text-muted-foreground' }, +}; + +export default function UsuariosPage() { + const { user: currentUser } = useAuthStore(); + const { data: usuarios, isLoading } = useUsuarios(); + const inviteUsuario = useInviteUsuario(); + const updateUsuario = useUpdateUsuario(); + const deleteUsuario = useDeleteUsuario(); + + const [showInvite, setShowInvite] = useState(false); + const [inviteForm, setInviteForm] = useState({ email: '', nombre: '', role: 'visor' as const }); + + const handleInvite = async (e: React.FormEvent) => { + e.preventDefault(); + try { + await inviteUsuario.mutateAsync(inviteForm); + setShowInvite(false); + setInviteForm({ email: '', nombre: '', role: 'visor' }); + } catch (error: any) { + alert(error.response?.data?.message || 'Error al invitar usuario'); + } + }; + + const handleToggleActive = (id: string, active: boolean) => { + updateUsuario.mutate({ id, data: { active: !active } }); + }; + + const handleDelete = (id: string) => { + if (confirm('¿Eliminar este usuario?')) { + deleteUsuario.mutate(id); + } + }; + + const isAdmin = currentUser?.role === 'admin'; + + return ( + +
+ {/* Header */} +
+
+ + {usuarios?.length || 0} usuarios +
+ {isAdmin && ( + + )} +
+ + {/* Invite Form */} + {showInvite && ( + + + Invitar Nuevo Usuario + + +
+
+
+ + setInviteForm({ ...inviteForm, email: e.target.value })} + required + /> +
+
+ + setInviteForm({ ...inviteForm, nombre: e.target.value })} + required + /> +
+
+ + +
+
+
+ + +
+
+
+
+ )} + + {/* Users List */} + + + {isLoading ? ( +
Cargando...
+ ) : ( +
+ {usuarios?.map(usuario => { + const roleInfo = roleLabels[usuario.role]; + const RoleIcon = roleInfo.icon; + const isCurrentUser = usuario.id === currentUser?.id; + + return ( +
+
+
+ {usuario.nombre.charAt(0).toUpperCase()} +
+
+
+ {usuario.nombre} + {isCurrentUser && ( + + )} + {!usuario.active && ( + Inactivo + )} +
+
{usuario.email}
+
+
+
+
+ + {roleInfo.label} +
+ {isAdmin && !isCurrentUser && ( +
+ + +
+ )} +
+
+ ); + })} +
+ )} +
+
+
+
+ ); +} +``` + +**Commit:** `git commit -m "feat(usuarios): add user management with invite and roles"` + +--- + +## Final: Actualizar Sidebar y Seed + +### F1: Actualizar Sidebar + +**File:** `apps/web/components/layouts/sidebar.tsx` + +**Add new navigation items:** +```typescript +const navigation = [ + { name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard }, + { name: 'CFDI', href: '/cfdi', icon: FileText }, + { name: 'Impuestos', href: '/impuestos', icon: Calculator }, + { name: 'Reportes', href: '/reportes', icon: BarChart3 }, + { name: 'Calendario', href: '/calendario', icon: Calendar }, + { name: 'Alertas', href: '/alertas', icon: Bell }, + { name: 'Usuarios', href: '/usuarios', icon: Users }, + { name: 'Configuración', href: '/configuracion', icon: Settings }, +]; +``` + +**Add imports:** +```typescript +import { BarChart3, Calendar, Bell, Users } from 'lucide-react'; +``` + +--- + +### F2: Actualizar Seed con Calendario Fiscal + +**File:** `apps/api/prisma/seed.ts` + +**Add calendario_fiscal table and demo data:** +```typescript +// Create calendario_fiscal table +await prisma.$executeRawUnsafe(` + CREATE TABLE IF NOT EXISTS "${schemaName}"."calendario_fiscal" ( + id SERIAL PRIMARY KEY, + titulo VARCHAR(200) NOT NULL, + descripcion TEXT, + tipo VARCHAR(20) NOT NULL, + fecha_limite TIMESTAMP NOT NULL, + recurrencia VARCHAR(20) DEFAULT 'mensual', + completado BOOLEAN DEFAULT FALSE, + notas TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) +`); + +// Insert demo fiscal events +const año = new Date().getFullYear(); +const eventos = [ + { titulo: 'Declaración mensual IVA', tipo: 'declaracion', dia: 17, recurrencia: 'mensual' }, + { titulo: 'Declaración mensual ISR', tipo: 'declaracion', dia: 17, recurrencia: 'mensual' }, + { titulo: 'Pago provisional ISR', tipo: 'pago', dia: 17, recurrencia: 'mensual' }, + { titulo: 'DIOT', tipo: 'obligacion', dia: 17, recurrencia: 'mensual' }, +]; + +for (let mes = 1; mes <= 12; mes++) { + for (const evento of eventos) { + await prisma.$executeRawUnsafe(` + INSERT INTO "${schemaName}"."calendario_fiscal" + (titulo, descripcion, tipo, fecha_limite, recurrencia) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT DO NOTHING + `, evento.titulo, `${evento.titulo} - ${mes}/${año}`, evento.tipo, + new Date(año, mes - 1, evento.dia).toISOString(), evento.recurrencia); + } +} + +console.log('✅ Calendario fiscal created'); +``` + +--- + +## Execution Summary + +**5 Parallel Modules:** +1. **Módulo A: Reportes** - Backend + Frontend reportes +2. **Módulo B: Exportación** - Excel export service +3. **Módulo C: Alertas** - CRUD alertas completo +4. **Módulo D: Calendario** - Calendario fiscal +5. **Módulo E: Usuarios** - Gestión de usuarios + +**Final commit:** `git commit -m "feat(fase3): complete phase 3 with reports, export, alerts, calendar, users"` diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index ecf2137..d9adc8f 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -5,6 +5,9 @@ export * from './types/user'; export * from './types/cfdi'; export * from './types/dashboard'; export * from './types/impuestos'; +export * from './types/alertas'; +export * from './types/reportes'; +export * from './types/calendario'; // Constants export * from './constants/plans'; diff --git a/packages/shared/src/types/alertas.ts b/packages/shared/src/types/alertas.ts new file mode 100644 index 0000000..ab04cbe --- /dev/null +++ b/packages/shared/src/types/alertas.ts @@ -0,0 +1,35 @@ +export type TipoAlerta = 'vencimiento' | 'discrepancia' | 'iva_favor' | 'declaracion' | 'limite_cfdi' | 'custom'; +export type PrioridadAlerta = 'alta' | 'media' | 'baja'; + +export interface AlertaCreate { + tipo: TipoAlerta; + titulo: string; + mensaje: string; + prioridad: PrioridadAlerta; + fechaVencimiento?: string; +} + +export interface AlertaUpdate { + leida?: boolean; + resuelta?: boolean; +} + +export interface AlertaFull { + id: number; + tipo: TipoAlerta; + titulo: string; + mensaje: string; + prioridad: PrioridadAlerta; + fechaVencimiento: string | null; + leida: boolean; + resuelta: boolean; + createdAt: string; +} + +export interface AlertasStats { + total: number; + noLeidas: number; + alta: number; + media: number; + baja: number; +} diff --git a/packages/shared/src/types/calendario.ts b/packages/shared/src/types/calendario.ts new file mode 100644 index 0000000..086cd0a --- /dev/null +++ b/packages/shared/src/types/calendario.ts @@ -0,0 +1,37 @@ +export type TipoEvento = 'declaracion' | 'pago' | 'obligacion' | 'custom'; +export type Recurrencia = 'mensual' | 'bimestral' | 'trimestral' | 'anual' | 'unica'; + +export interface EventoFiscal { + id: number; + titulo: string; + descripcion: string; + tipo: TipoEvento; + fechaLimite: string; + recurrencia: Recurrencia; + completado: boolean; + notas: string | null; + createdAt: string; +} + +export interface EventoCreate { + titulo: string; + descripcion: string; + tipo: TipoEvento; + fechaLimite: string; + recurrencia: Recurrencia; + notas?: string; +} + +export interface EventoUpdate { + titulo?: string; + descripcion?: string; + fechaLimite?: string; + completado?: boolean; + notas?: string; +} + +export interface CalendarioMes { + año: number; + mes: number; + eventos: EventoFiscal[]; +} diff --git a/packages/shared/src/types/reportes.ts b/packages/shared/src/types/reportes.ts new file mode 100644 index 0000000..f451563 --- /dev/null +++ b/packages/shared/src/types/reportes.ts @@ -0,0 +1,46 @@ +export interface EstadoResultados { + periodo: { inicio: string; fin: string }; + ingresos: { concepto: string; monto: number }[]; + egresos: { concepto: string; monto: number }[]; + totalIngresos: number; + totalEgresos: number; + utilidadBruta: number; + impuestos: number; + utilidadNeta: number; +} + +export interface FlujoEfectivo { + periodo: { inicio: string; fin: string }; + saldoInicial: number; + entradas: { concepto: string; monto: number }[]; + salidas: { concepto: string; monto: number }[]; + totalEntradas: number; + totalSalidas: number; + flujoNeto: number; + saldoFinal: number; +} + +export interface ComparativoPeriodos { + periodos: string[]; + ingresos: number[]; + egresos: number[]; + utilidad: number[]; + variacionIngresos: number; + variacionEgresos: number; + variacionUtilidad: number; +} + +export interface ConcentradoRfc { + rfc: string; + nombre: string; + tipo: 'cliente' | 'proveedor'; + totalFacturado: number; + totalIva: number; + cantidadCfdis: number; +} + +export interface ReporteFilters { + fechaInicio: string; + fechaFin: string; + tipo?: 'mensual' | 'trimestral' | 'anual'; +} diff --git a/packages/shared/src/types/user.ts b/packages/shared/src/types/user.ts index c9f77a2..e5f6fae 100644 --- a/packages/shared/src/types/user.ts +++ b/packages/shared/src/types/user.ts @@ -23,3 +23,35 @@ export interface UpdateUserRequest { role?: Role; active?: boolean; } + +export interface UserInvite { + email: string; + nombre: string; + role: 'admin' | 'contador' | 'visor'; +} + +export interface UserListItem { + id: string; + email: string; + nombre: string; + role: 'admin' | 'contador' | 'visor'; + active: boolean; + lastLogin: string | null; + createdAt: string; +} + +export interface UserUpdate { + nombre?: string; + role?: 'admin' | 'contador' | 'visor'; + active?: boolean; +} + +export interface AuditLog { + id: number; + userId: string; + userName: string; + action: string; + details: string; + ip: string; + createdAt: string; +}