# 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"`