Files
Horux360/docs/plans/2026-01-22-fase3-implementation.md
2026-01-22 03:02:20 +00:00

92 KiB

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:

// 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:

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):

import { prisma } from '../config/database.js';
import type { EstadoResultados, FlujoEfectivo, ComparativoPeriodos, ConcentradoRfc } from '@horux/shared';

export async function getEstadoResultados(
  schema: string,
  fechaInicio: string,
  fechaFin: string
): Promise<EstadoResultados> {
  const ingresos = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; total: number }[]>(`
    SELECT rfc_receptor as rfc, nombre_receptor as nombre, SUM(subtotal) as total
    FROM "${schema}".cfdis
    WHERE tipo = 'ingreso' AND estado = 'vigente'
    AND fecha_emision BETWEEN $1 AND $2
    GROUP BY rfc_receptor, nombre_receptor
    ORDER BY total DESC LIMIT 10
  `, fechaInicio, fechaFin);

  const egresos = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; total: number }[]>(`
    SELECT rfc_emisor as rfc, nombre_emisor as nombre, SUM(subtotal) as total
    FROM "${schema}".cfdis
    WHERE tipo = 'egreso' AND estado = 'vigente'
    AND fecha_emision BETWEEN $1 AND $2
    GROUP BY rfc_emisor, nombre_emisor
    ORDER BY total DESC LIMIT 10
  `, fechaInicio, fechaFin);

  const [totales] = await prisma.$queryRawUnsafe<[{ ingresos: number; egresos: number; iva: number }]>(`
    SELECT
      COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN subtotal ELSE 0 END), 0) as ingresos,
      COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN subtotal ELSE 0 END), 0) as egresos,
      COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) -
      COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as iva
    FROM "${schema}".cfdis
    WHERE estado = 'vigente' AND fecha_emision BETWEEN $1 AND $2
  `, fechaInicio, fechaFin);

  const totalIngresos = Number(totales?.ingresos || 0);
  const totalEgresos = Number(totales?.egresos || 0);
  const utilidadBruta = totalIngresos - totalEgresos;
  const impuestos = Number(totales?.iva || 0);

  return {
    periodo: { inicio: fechaInicio, fin: fechaFin },
    ingresos: ingresos.map(i => ({ concepto: i.nombre, monto: Number(i.total) })),
    egresos: egresos.map(e => ({ concepto: e.nombre, monto: Number(e.total) })),
    totalIngresos,
    totalEgresos,
    utilidadBruta,
    impuestos,
    utilidadNeta: utilidadBruta - (impuestos > 0 ? impuestos : 0),
  };
}

export async function getFlujoEfectivo(
  schema: string,
  fechaInicio: string,
  fechaFin: string
): Promise<FlujoEfectivo> {
  const entradas = await prisma.$queryRawUnsafe<{ mes: string; total: number }[]>(`
    SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, SUM(total) as total
    FROM "${schema}".cfdis
    WHERE tipo = 'ingreso' AND estado = 'vigente'
    AND fecha_emision BETWEEN $1 AND $2
    GROUP BY TO_CHAR(fecha_emision, 'YYYY-MM')
    ORDER BY mes
  `, fechaInicio, fechaFin);

  const salidas = await prisma.$queryRawUnsafe<{ mes: string; total: number }[]>(`
    SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, SUM(total) as total
    FROM "${schema}".cfdis
    WHERE tipo = 'egreso' AND estado = 'vigente'
    AND fecha_emision BETWEEN $1 AND $2
    GROUP BY TO_CHAR(fecha_emision, 'YYYY-MM')
    ORDER BY mes
  `, fechaInicio, fechaFin);

  const totalEntradas = entradas.reduce((sum, e) => sum + Number(e.total), 0);
  const totalSalidas = salidas.reduce((sum, s) => sum + Number(s.total), 0);

  return {
    periodo: { inicio: fechaInicio, fin: fechaFin },
    saldoInicial: 0,
    entradas: entradas.map(e => ({ concepto: e.mes, monto: Number(e.total) })),
    salidas: salidas.map(s => ({ concepto: s.mes, monto: Number(s.total) })),
    totalEntradas,
    totalSalidas,
    flujoNeto: totalEntradas - totalSalidas,
    saldoFinal: totalEntradas - totalSalidas,
  };
}

export async function getComparativo(
  schema: string,
  año: number
): Promise<ComparativoPeriodos> {
  const actual = await prisma.$queryRawUnsafe<{ mes: number; ingresos: number; egresos: number }[]>(`
    SELECT EXTRACT(MONTH FROM fecha_emision)::int as mes,
      COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN total ELSE 0 END), 0) as ingresos,
      COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as egresos
    FROM "${schema}".cfdis
    WHERE estado = 'vigente' AND EXTRACT(YEAR FROM fecha_emision) = $1
    GROUP BY mes ORDER BY mes
  `, año);

  const anterior = await prisma.$queryRawUnsafe<{ mes: number; ingresos: number; egresos: number }[]>(`
    SELECT EXTRACT(MONTH FROM fecha_emision)::int as mes,
      COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN total ELSE 0 END), 0) as ingresos,
      COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as egresos
    FROM "${schema}".cfdis
    WHERE estado = 'vigente' AND EXTRACT(YEAR FROM fecha_emision) = $1
    GROUP BY mes ORDER BY mes
  `, año - 1);

  const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
  const ingresos = meses.map((_, i) => Number(actual.find(a => a.mes === i + 1)?.ingresos || 0));
  const egresos = meses.map((_, i) => Number(actual.find(a => a.mes === i + 1)?.egresos || 0));
  const utilidad = ingresos.map((ing, i) => ing - egresos[i]);

  const totalActualIng = ingresos.reduce((a, b) => a + b, 0);
  const totalAnteriorIng = anterior.reduce((a, b) => a + Number(b.ingresos), 0);
  const totalActualEgr = egresos.reduce((a, b) => a + b, 0);
  const totalAnteriorEgr = anterior.reduce((a, b) => a + Number(b.egresos), 0);

  return {
    periodos: meses,
    ingresos,
    egresos,
    utilidad,
    variacionIngresos: totalAnteriorIng > 0 ? ((totalActualIng - totalAnteriorIng) / totalAnteriorIng) * 100 : 0,
    variacionEgresos: totalAnteriorEgr > 0 ? ((totalActualEgr - totalAnteriorEgr) / totalAnteriorEgr) * 100 : 0,
    variacionUtilidad: 0,
  };
}

export async function getConcentradoRfc(
  schema: string,
  fechaInicio: string,
  fechaFin: string,
  tipo: 'cliente' | 'proveedor'
): Promise<ConcentradoRfc[]> {
  if (tipo === 'cliente') {
    const data = await prisma.$queryRawUnsafe<ConcentradoRfc[]>(`
      SELECT rfc_receptor as rfc, nombre_receptor as nombre,
        'cliente' as tipo,
        SUM(total) as "totalFacturado",
        SUM(iva) as "totalIva",
        COUNT(*)::int as "cantidadCfdis"
      FROM "${schema}".cfdis
      WHERE tipo = 'ingreso' AND estado = 'vigente'
      AND fecha_emision BETWEEN $1 AND $2
      GROUP BY rfc_receptor, nombre_receptor
      ORDER BY "totalFacturado" DESC
    `, fechaInicio, fechaFin);
    return data.map(d => ({ ...d, totalFacturado: Number(d.totalFacturado), totalIva: Number(d.totalIva) }));
  } else {
    const data = await prisma.$queryRawUnsafe<ConcentradoRfc[]>(`
      SELECT rfc_emisor as rfc, nombre_emisor as nombre,
        'proveedor' as tipo,
        SUM(total) as "totalFacturado",
        SUM(iva) as "totalIva",
        COUNT(*)::int as "cantidadCfdis"
      FROM "${schema}".cfdis
      WHERE tipo = 'egreso' AND estado = 'vigente'
      AND fecha_emision BETWEEN $1 AND $2
      GROUP BY rfc_emisor, nombre_emisor
      ORDER BY "totalFacturado" DESC
    `, fechaInicio, fechaFin);
    return data.map(d => ({ ...d, totalFacturado: Number(d.totalFacturado), totalIva: Number(d.totalIva) }));
  }
}

Controller (reportes.controller.ts):

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):

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:

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):

import { apiClient } from './client';
import type { EstadoResultados, FlujoEfectivo, ComparativoPeriodos, ConcentradoRfc } from '@horux/shared';

export async function getEstadoResultados(fechaInicio?: string, fechaFin?: string): Promise<EstadoResultados> {
  const params = new URLSearchParams();
  if (fechaInicio) params.set('fechaInicio', fechaInicio);
  if (fechaFin) params.set('fechaFin', fechaFin);
  const response = await apiClient.get<EstadoResultados>(`/reportes/estado-resultados?${params}`);
  return response.data;
}

export async function getFlujoEfectivo(fechaInicio?: string, fechaFin?: string): Promise<FlujoEfectivo> {
  const params = new URLSearchParams();
  if (fechaInicio) params.set('fechaInicio', fechaInicio);
  if (fechaFin) params.set('fechaFin', fechaFin);
  const response = await apiClient.get<FlujoEfectivo>(`/reportes/flujo-efectivo?${params}`);
  return response.data;
}

export async function getComparativo(año?: number): Promise<ComparativoPeriodos> {
  const params = año ? `?año=${año}` : '';
  const response = await apiClient.get<ComparativoPeriodos>(`/reportes/comparativo${params}`);
  return response.data;
}

export async function getConcentradoRfc(
  tipo: 'cliente' | 'proveedor',
  fechaInicio?: string,
  fechaFin?: string
): Promise<ConcentradoRfc[]> {
  const params = new URLSearchParams({ tipo });
  if (fechaInicio) params.set('fechaInicio', fechaInicio);
  if (fechaFin) params.set('fechaFin', fechaFin);
  const response = await apiClient.get<ConcentradoRfc[]>(`/reportes/concentrado-rfc?${params}`);
  return response.data;
}

Hooks (use-reportes.ts):

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):

'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 (
    <DashboardShell
      title="Reportes"
      description="Análisis financiero y reportes fiscales"
    >
      <Tabs defaultValue="estado-resultados" className="space-y-4">
        <TabsList>
          <TabsTrigger value="estado-resultados">Estado de Resultados</TabsTrigger>
          <TabsTrigger value="flujo-efectivo">Flujo de Efectivo</TabsTrigger>
          <TabsTrigger value="comparativo">Comparativo</TabsTrigger>
          <TabsTrigger value="concentrado">Concentrado RFC</TabsTrigger>
        </TabsList>

        <TabsContent value="estado-resultados" className="space-y-4">
          {loadingER ? (
            <div className="text-center py-8 text-muted-foreground">Cargando...</div>
          ) : estadoResultados ? (
            <>
              <div className="grid gap-4 md:grid-cols-4">
                <Card>
                  <CardHeader className="flex flex-row items-center justify-between pb-2">
                    <CardTitle className="text-sm font-medium">Total Ingresos</CardTitle>
                    <TrendingUp className="h-4 w-4 text-success" />
                  </CardHeader>
                  <CardContent>
                    <div className="text-2xl font-bold text-success">
                      {formatCurrency(estadoResultados.totalIngresos)}
                    </div>
                  </CardContent>
                </Card>
                <Card>
                  <CardHeader className="flex flex-row items-center justify-between pb-2">
                    <CardTitle className="text-sm font-medium">Total Egresos</CardTitle>
                    <TrendingDown className="h-4 w-4 text-destructive" />
                  </CardHeader>
                  <CardContent>
                    <div className="text-2xl font-bold text-destructive">
                      {formatCurrency(estadoResultados.totalEgresos)}
                    </div>
                  </CardContent>
                </Card>
                <Card>
                  <CardHeader className="flex flex-row items-center justify-between pb-2">
                    <CardTitle className="text-sm font-medium">Utilidad Bruta</CardTitle>
                    <FileText className="h-4 w-4 text-muted-foreground" />
                  </CardHeader>
                  <CardContent>
                    <div className={`text-2xl font-bold ${estadoResultados.utilidadBruta >= 0 ? 'text-success' : 'text-destructive'}`}>
                      {formatCurrency(estadoResultados.utilidadBruta)}
                    </div>
                  </CardContent>
                </Card>
                <Card>
                  <CardHeader className="flex flex-row items-center justify-between pb-2">
                    <CardTitle className="text-sm font-medium">Utilidad Neta</CardTitle>
                    <FileText className="h-4 w-4 text-muted-foreground" />
                  </CardHeader>
                  <CardContent>
                    <div className={`text-2xl font-bold ${estadoResultados.utilidadNeta >= 0 ? 'text-success' : 'text-destructive'}`}>
                      {formatCurrency(estadoResultados.utilidadNeta)}
                    </div>
                  </CardContent>
                </Card>
              </div>

              <div className="grid gap-4 md:grid-cols-2">
                <Card>
                  <CardHeader>
                    <CardTitle>Top 10 Ingresos por Cliente</CardTitle>
                  </CardHeader>
                  <CardContent>
                    <div className="space-y-2">
                      {estadoResultados.ingresos.map((item, i) => (
                        <div key={i} className="flex justify-between items-center py-2 border-b last:border-0">
                          <span className="text-sm truncate max-w-[200px]">{item.concepto}</span>
                          <span className="font-medium">{formatCurrency(item.monto)}</span>
                        </div>
                      ))}
                    </div>
                  </CardContent>
                </Card>
                <Card>
                  <CardHeader>
                    <CardTitle>Top 10 Egresos por Proveedor</CardTitle>
                  </CardHeader>
                  <CardContent>
                    <div className="space-y-2">
                      {estadoResultados.egresos.map((item, i) => (
                        <div key={i} className="flex justify-between items-center py-2 border-b last:border-0">
                          <span className="text-sm truncate max-w-[200px]">{item.concepto}</span>
                          <span className="font-medium">{formatCurrency(item.monto)}</span>
                        </div>
                      ))}
                    </div>
                  </CardContent>
                </Card>
              </div>
            </>
          ) : null}
        </TabsContent>

        <TabsContent value="flujo-efectivo" className="space-y-4">
          {loadingFE ? (
            <div className="text-center py-8 text-muted-foreground">Cargando...</div>
          ) : flujoEfectivo ? (
            <>
              <div className="grid gap-4 md:grid-cols-3">
                <Card>
                  <CardHeader className="pb-2">
                    <CardTitle className="text-sm font-medium">Total Entradas</CardTitle>
                  </CardHeader>
                  <CardContent>
                    <div className="text-2xl font-bold text-success">
                      {formatCurrency(flujoEfectivo.totalEntradas)}
                    </div>
                  </CardContent>
                </Card>
                <Card>
                  <CardHeader className="pb-2">
                    <CardTitle className="text-sm font-medium">Total Salidas</CardTitle>
                  </CardHeader>
                  <CardContent>
                    <div className="text-2xl font-bold text-destructive">
                      {formatCurrency(flujoEfectivo.totalSalidas)}
                    </div>
                  </CardContent>
                </Card>
                <Card>
                  <CardHeader className="pb-2">
                    <CardTitle className="text-sm font-medium">Flujo Neto</CardTitle>
                  </CardHeader>
                  <CardContent>
                    <div className={`text-2xl font-bold ${flujoEfectivo.flujoNeto >= 0 ? 'text-success' : 'text-destructive'}`}>
                      {formatCurrency(flujoEfectivo.flujoNeto)}
                    </div>
                  </CardContent>
                </Card>
              </div>

              <Card>
                <CardHeader>
                  <CardTitle>Flujo de Efectivo Mensual</CardTitle>
                </CardHeader>
                <CardContent>
                  <BarChart
                    data={flujoEfectivo.entradas.map((e, i) => ({
                      mes: e.concepto,
                      ingresos: e.monto,
                      egresos: flujoEfectivo.salidas[i]?.monto || 0,
                    }))}
                  />
                </CardContent>
              </Card>
            </>
          ) : null}
        </TabsContent>

        <TabsContent value="comparativo" className="space-y-4">
          {loadingComp ? (
            <div className="text-center py-8 text-muted-foreground">Cargando...</div>
          ) : comparativo ? (
            <>
              <div className="grid gap-4 md:grid-cols-3">
                <Card>
                  <CardHeader className="pb-2">
                    <CardTitle className="text-sm font-medium">Var. Ingresos vs Año Anterior</CardTitle>
                  </CardHeader>
                  <CardContent>
                    <div className={`text-2xl font-bold ${comparativo.variacionIngresos >= 0 ? 'text-success' : 'text-destructive'}`}>
                      {comparativo.variacionIngresos >= 0 ? '+' : ''}{comparativo.variacionIngresos.toFixed(1)}%
                    </div>
                  </CardContent>
                </Card>
                <Card>
                  <CardHeader className="pb-2">
                    <CardTitle className="text-sm font-medium">Var. Egresos vs Año Anterior</CardTitle>
                  </CardHeader>
                  <CardContent>
                    <div className={`text-2xl font-bold ${comparativo.variacionEgresos <= 0 ? 'text-success' : 'text-destructive'}`}>
                      {comparativo.variacionEgresos >= 0 ? '+' : ''}{comparativo.variacionEgresos.toFixed(1)}%
                    </div>
                  </CardContent>
                </Card>
                <Card>
                  <CardHeader className="pb-2">
                    <CardTitle className="text-sm font-medium">Año Actual</CardTitle>
                  </CardHeader>
                  <CardContent>
                    <div className="text-2xl font-bold">{año}</div>
                  </CardContent>
                </Card>
              </div>

              <Card>
                <CardHeader>
                  <CardTitle>Comparativo Mensual {año}</CardTitle>
                </CardHeader>
                <CardContent>
                  <BarChart
                    data={comparativo.periodos.map((mes, i) => ({
                      mes,
                      ingresos: comparativo.ingresos[i],
                      egresos: comparativo.egresos[i],
                    }))}
                  />
                </CardContent>
              </Card>
            </>
          ) : null}
        </TabsContent>

        <TabsContent value="concentrado" className="space-y-4">
          <div className="grid gap-4 md:grid-cols-2">
            <Card>
              <CardHeader>
                <CardTitle className="flex items-center gap-2">
                  <Users className="h-5 w-5" />
                  Clientes
                </CardTitle>
              </CardHeader>
              <CardContent>
                <div className="space-y-2">
                  {clientes?.slice(0, 10).map((c, i) => (
                    <div key={i} className="flex justify-between items-center py-2 border-b last:border-0">
                      <div>
                        <div className="font-medium text-sm">{c.nombre}</div>
                        <div className="text-xs text-muted-foreground">{c.rfc} · {c.cantidadCfdis} CFDIs</div>
                      </div>
                      <span className="font-medium">{formatCurrency(c.totalFacturado)}</span>
                    </div>
                  ))}
                </div>
              </CardContent>
            </Card>
            <Card>
              <CardHeader>
                <CardTitle className="flex items-center gap-2">
                  <Users className="h-5 w-5" />
                  Proveedores
                </CardTitle>
              </CardHeader>
              <CardContent>
                <div className="space-y-2">
                  {proveedores?.slice(0, 10).map((p, i) => (
                    <div key={i} className="flex justify-between items-center py-2 border-b last:border-0">
                      <div>
                        <div className="font-medium text-sm">{p.nombre}</div>
                        <div className="text-xs text-muted-foreground">{p.rfc} · {p.cantidadCfdis} CFDIs</div>
                      </div>
                      <span className="font-medium">{formatCurrency(p.totalFacturado)}</span>
                    </div>
                  ))}
                </div>
              </CardContent>
            </Card>
          </div>
        </TabsContent>
      </Tabs>
    </DashboardShell>
  );
}

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:

"exceljs": "^4.4.0"

Web package.json - add:

"@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):

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<Buffer> {
  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<any[]>(`
    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<Buffer> {
  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):

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):

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:

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:

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):

import { prisma } from '../config/database.js';
import type { AlertaFull, AlertaCreate, AlertaUpdate, AlertasStats } from '@horux/shared';

export async function getAlertas(
  schema: string,
  filters: { leida?: boolean; resuelta?: boolean; prioridad?: string }
): Promise<AlertaFull[]> {
  let whereClause = 'WHERE 1=1';
  const params: any[] = [];
  let paramIndex = 1;

  if (filters.leida !== undefined) {
    whereClause += ` AND leida = $${paramIndex++}`;
    params.push(filters.leida);
  }
  if (filters.resuelta !== undefined) {
    whereClause += ` AND resuelta = $${paramIndex++}`;
    params.push(filters.resuelta);
  }
  if (filters.prioridad) {
    whereClause += ` AND prioridad = $${paramIndex++}`;
    params.push(filters.prioridad);
  }

  const alertas = await prisma.$queryRawUnsafe<AlertaFull[]>(`
    SELECT id, tipo, titulo, mensaje, prioridad,
           fecha_vencimiento as "fechaVencimiento",
           leida, resuelta, created_at as "createdAt"
    FROM "${schema}".alertas
    ${whereClause}
    ORDER BY
      CASE prioridad WHEN 'alta' THEN 1 WHEN 'media' THEN 2 ELSE 3 END,
      created_at DESC
  `, ...params);

  return alertas;
}

export async function getAlertaById(schema: string, id: number): Promise<AlertaFull | null> {
  const [alerta] = await prisma.$queryRawUnsafe<AlertaFull[]>(`
    SELECT id, tipo, titulo, mensaje, prioridad,
           fecha_vencimiento as "fechaVencimiento",
           leida, resuelta, created_at as "createdAt"
    FROM "${schema}".alertas
    WHERE id = $1
  `, id);
  return alerta || null;
}

export async function createAlerta(schema: string, data: AlertaCreate): Promise<AlertaFull> {
  const [alerta] = await prisma.$queryRawUnsafe<AlertaFull[]>(`
    INSERT INTO "${schema}".alertas (tipo, titulo, mensaje, prioridad, fecha_vencimiento)
    VALUES ($1, $2, $3, $4, $5)
    RETURNING id, tipo, titulo, mensaje, prioridad,
              fecha_vencimiento as "fechaVencimiento",
              leida, resuelta, created_at as "createdAt"
  `, data.tipo, data.titulo, data.mensaje, data.prioridad, data.fechaVencimiento || null);
  return alerta;
}

export async function updateAlerta(schema: string, id: number, data: AlertaUpdate): Promise<AlertaFull> {
  const sets: string[] = [];
  const params: any[] = [];
  let paramIndex = 1;

  if (data.leida !== undefined) {
    sets.push(`leida = $${paramIndex++}`);
    params.push(data.leida);
  }
  if (data.resuelta !== undefined) {
    sets.push(`resuelta = $${paramIndex++}`);
    params.push(data.resuelta);
  }

  params.push(id);

  const [alerta] = await prisma.$queryRawUnsafe<AlertaFull[]>(`
    UPDATE "${schema}".alertas
    SET ${sets.join(', ')}
    WHERE id = $${paramIndex}
    RETURNING id, tipo, titulo, mensaje, prioridad,
              fecha_vencimiento as "fechaVencimiento",
              leida, resuelta, created_at as "createdAt"
  `, ...params);

  return alerta;
}

export async function deleteAlerta(schema: string, id: number): Promise<void> {
  await prisma.$queryRawUnsafe(`DELETE FROM "${schema}".alertas WHERE id = $1`, id);
}

export async function getStats(schema: string): Promise<AlertasStats> {
  const [stats] = await prisma.$queryRawUnsafe<AlertasStats[]>(`
    SELECT
      COUNT(*)::int as total,
      COUNT(CASE WHEN leida = false THEN 1 END)::int as "noLeidas",
      COUNT(CASE WHEN prioridad = 'alta' AND resuelta = false THEN 1 END)::int as alta,
      COUNT(CASE WHEN prioridad = 'media' AND resuelta = false THEN 1 END)::int as media,
      COUNT(CASE WHEN prioridad = 'baja' AND resuelta = false THEN 1 END)::int as baja
    FROM "${schema}".alertas
  `);
  return stats;
}

export async function markAllAsRead(schema: string): Promise<void> {
  await prisma.$queryRawUnsafe(`UPDATE "${schema}".alertas SET leida = true WHERE leida = false`);
}

Controller (alertas.controller.ts):

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):

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:

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):

import { apiClient } from './client';
import type { AlertaFull, AlertaCreate, AlertaUpdate, AlertasStats } from '@horux/shared';

export async function getAlertas(filters?: { leida?: boolean; resuelta?: boolean }): Promise<AlertaFull[]> {
  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<AlertaFull[]>(`/alertas?${params}`);
  return response.data;
}

export async function getStats(): Promise<AlertasStats> {
  const response = await apiClient.get<AlertasStats>('/alertas/stats');
  return response.data;
}

export async function createAlerta(data: AlertaCreate): Promise<AlertaFull> {
  const response = await apiClient.post<AlertaFull>('/alertas', data);
  return response.data;
}

export async function updateAlerta(id: number, data: AlertaUpdate): Promise<AlertaFull> {
  const response = await apiClient.patch<AlertaFull>(`/alertas/${id}`, data);
  return response.data;
}

export async function deleteAlerta(id: number): Promise<void> {
  await apiClient.delete(`/alertas/${id}`);
}

export async function markAllAsRead(): Promise<void> {
  await apiClient.post('/alertas/mark-all-read');
}

Hooks (use-alertas.ts):

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):

'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 (
    <DashboardShell
      title="Alertas"
      description="Gestiona tus alertas y notificaciones"
    >
      <div className="space-y-4">
        {/* Stats */}
        <div className="grid gap-4 md:grid-cols-4">
          <Card>
            <CardHeader className="flex flex-row items-center justify-between pb-2">
              <CardTitle className="text-sm font-medium">Total</CardTitle>
              <Bell className="h-4 w-4 text-muted-foreground" />
            </CardHeader>
            <CardContent>
              <div className="text-2xl font-bold">{stats?.total || 0}</div>
            </CardContent>
          </Card>
          <Card>
            <CardHeader className="flex flex-row items-center justify-between pb-2">
              <CardTitle className="text-sm font-medium">No Leídas</CardTitle>
              <AlertCircle className="h-4 w-4 text-destructive" />
            </CardHeader>
            <CardContent>
              <div className="text-2xl font-bold text-destructive">{stats?.noLeidas || 0}</div>
            </CardContent>
          </Card>
          <Card>
            <CardHeader className="flex flex-row items-center justify-between pb-2">
              <CardTitle className="text-sm font-medium">Alta Prioridad</CardTitle>
              <AlertTriangle className="h-4 w-4 text-warning" />
            </CardHeader>
            <CardContent>
              <div className="text-2xl font-bold text-warning">{stats?.alta || 0}</div>
            </CardContent>
          </Card>
          <Card>
            <CardHeader className="flex flex-row items-center justify-between pb-2">
              <CardTitle className="text-sm font-medium">Pendientes</CardTitle>
              <Info className="h-4 w-4 text-muted-foreground" />
            </CardHeader>
            <CardContent>
              <div className="text-2xl font-bold">{(stats?.alta || 0) + (stats?.media || 0) + (stats?.baja || 0)}</div>
            </CardContent>
          </Card>
        </div>

        {/* Filters */}
        <div className="flex gap-2">
          <Button
            variant={filter === 'todas' ? 'default' : 'outline'}
            size="sm"
            onClick={() => setFilter('todas')}
          >
            Todas
          </Button>
          <Button
            variant={filter === 'pendientes' ? 'default' : 'outline'}
            size="sm"
            onClick={() => setFilter('pendientes')}
          >
            Pendientes
          </Button>
          <Button
            variant={filter === 'resueltas' ? 'default' : 'outline'}
            size="sm"
            onClick={() => setFilter('resueltas')}
          >
            Resueltas
          </Button>
          <div className="flex-1" />
          <Button
            variant="outline"
            size="sm"
            onClick={() => markAllAsRead.mutate()}
            disabled={markAllAsRead.isPending}
          >
            Marcar todas como leídas
          </Button>
        </div>

        {/* Alertas List */}
        <div className="space-y-2">
          {isLoading ? (
            <div className="text-center py-8 text-muted-foreground">Cargando...</div>
          ) : alertas?.length === 0 ? (
            <Card>
              <CardContent className="py-8 text-center text-muted-foreground">
                <CheckCircle className="h-12 w-12 mx-auto mb-4 text-success" />
                <p>No hay alertas {filter === 'pendientes' ? 'pendientes' : ''}</p>
              </CardContent>
            </Card>
          ) : (
            alertas?.map((alerta) => {
              const Icon = prioridadIcons[alerta.prioridad];
              return (
                <Card key={alerta.id} className={cn(prioridadStyles[alerta.prioridad], alerta.leida && 'opacity-60')}>
                  <CardContent className="py-4">
                    <div className="flex items-start gap-4">
                      <Icon className={cn(
                        'h-5 w-5 mt-0.5',
                        alerta.prioridad === 'alta' && 'text-destructive',
                        alerta.prioridad === 'media' && 'text-warning',
                        alerta.prioridad === 'baja' && 'text-muted-foreground'
                      )} />
                      <div className="flex-1">
                        <div className="flex items-center gap-2">
                          <h4 className="font-medium">{alerta.titulo}</h4>
                          {!alerta.leida && (
                            <span className="px-2 py-0.5 text-xs bg-primary text-primary-foreground rounded-full">
                              Nueva
                            </span>
                          )}
                        </div>
                        <p className="text-sm text-muted-foreground mt-1">{alerta.mensaje}</p>
                        <div className="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
                          <span>{new Date(alerta.createdAt).toLocaleDateString('es-MX')}</span>
                          {alerta.fechaVencimiento && (
                            <span>Vence: {new Date(alerta.fechaVencimiento).toLocaleDateString('es-MX')}</span>
                          )}
                        </div>
                      </div>
                      <div className="flex gap-1">
                        {!alerta.leida && (
                          <Button
                            variant="ghost"
                            size="icon"
                            onClick={() => handleMarkAsRead(alerta.id)}
                            title="Marcar como leída"
                          >
                            <Check className="h-4 w-4" />
                          </Button>
                        )}
                        {!alerta.resuelta && (
                          <Button
                            variant="ghost"
                            size="icon"
                            onClick={() => handleResolve(alerta.id)}
                            title="Marcar como resuelta"
                          >
                            <CheckCircle className="h-4 w-4" />
                          </Button>
                        )}
                        <Button
                          variant="ghost"
                          size="icon"
                          onClick={() => handleDelete(alerta.id)}
                          title="Eliminar"
                        >
                          <Trash2 className="h-4 w-4" />
                        </Button>
                      </div>
                    </div>
                  </CardContent>
                </Card>
              );
            })
          )}
        </div>
      </div>
    </DashboardShell>
  );
}

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:

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):

import { prisma } from '../config/database.js';
import type { EventoFiscal, EventoCreate, EventoUpdate } from '@horux/shared';

export async function getEventos(
  schema: string,
  año: number,
  mes?: number
): Promise<EventoFiscal[]> {
  let whereClause = `WHERE EXTRACT(YEAR FROM fecha_limite) = $1`;
  const params: any[] = [año];

  if (mes) {
    whereClause += ` AND EXTRACT(MONTH FROM fecha_limite) = $2`;
    params.push(mes);
  }

  const eventos = await prisma.$queryRawUnsafe<EventoFiscal[]>(`
    SELECT id, titulo, descripcion, tipo,
           fecha_limite as "fechaLimite",
           recurrencia, completado, notas,
           created_at as "createdAt"
    FROM "${schema}".calendario_fiscal
    ${whereClause}
    ORDER BY fecha_limite ASC
  `, ...params);

  return eventos;
}

export async function getProximosEventos(schema: string, dias = 30): Promise<EventoFiscal[]> {
  const eventos = await prisma.$queryRawUnsafe<EventoFiscal[]>(`
    SELECT id, titulo, descripcion, tipo,
           fecha_limite as "fechaLimite",
           recurrencia, completado, notas,
           created_at as "createdAt"
    FROM "${schema}".calendario_fiscal
    WHERE completado = false
    AND fecha_limite BETWEEN CURRENT_DATE AND CURRENT_DATE + INTERVAL '${dias} days'
    ORDER BY fecha_limite ASC
  `);

  return eventos;
}

export async function createEvento(schema: string, data: EventoCreate): Promise<EventoFiscal> {
  const [evento] = await prisma.$queryRawUnsafe<EventoFiscal[]>(`
    INSERT INTO "${schema}".calendario_fiscal
    (titulo, descripcion, tipo, fecha_limite, recurrencia, notas)
    VALUES ($1, $2, $3, $4, $5, $6)
    RETURNING id, titulo, descripcion, tipo,
              fecha_limite as "fechaLimite",
              recurrencia, completado, notas,
              created_at as "createdAt"
  `, data.titulo, data.descripcion, data.tipo, data.fechaLimite, data.recurrencia, data.notas || null);

  return evento;
}

export async function updateEvento(schema: string, id: number, data: EventoUpdate): Promise<EventoFiscal> {
  const sets: string[] = [];
  const params: any[] = [];
  let paramIndex = 1;

  if (data.titulo !== undefined) {
    sets.push(`titulo = $${paramIndex++}`);
    params.push(data.titulo);
  }
  if (data.descripcion !== undefined) {
    sets.push(`descripcion = $${paramIndex++}`);
    params.push(data.descripcion);
  }
  if (data.fechaLimite !== undefined) {
    sets.push(`fecha_limite = $${paramIndex++}`);
    params.push(data.fechaLimite);
  }
  if (data.completado !== undefined) {
    sets.push(`completado = $${paramIndex++}`);
    params.push(data.completado);
  }
  if (data.notas !== undefined) {
    sets.push(`notas = $${paramIndex++}`);
    params.push(data.notas);
  }

  params.push(id);

  const [evento] = await prisma.$queryRawUnsafe<EventoFiscal[]>(`
    UPDATE "${schema}".calendario_fiscal
    SET ${sets.join(', ')}
    WHERE id = $${paramIndex}
    RETURNING id, titulo, descripcion, tipo,
              fecha_limite as "fechaLimite",
              recurrencia, completado, notas,
              created_at as "createdAt"
  `, ...params);

  return evento;
}

export async function deleteEvento(schema: string, id: number): Promise<void> {
  await prisma.$queryRawUnsafe(`DELETE FROM "${schema}".calendario_fiscal WHERE id = $1`, id);
}

Controller (calendario.controller.ts):

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):

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:

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):

import { apiClient } from './client';
import type { EventoFiscal, EventoCreate, EventoUpdate } from '@horux/shared';

export async function getEventos(año: number, mes?: number): Promise<EventoFiscal[]> {
  const params = new URLSearchParams({ año: año.toString() });
  if (mes) params.set('mes', mes.toString());
  const response = await apiClient.get<EventoFiscal[]>(`/calendario?${params}`);
  return response.data;
}

export async function getProximos(dias = 30): Promise<EventoFiscal[]> {
  const response = await apiClient.get<EventoFiscal[]>(`/calendario/proximos?dias=${dias}`);
  return response.data;
}

export async function createEvento(data: EventoCreate): Promise<EventoFiscal> {
  const response = await apiClient.post<EventoFiscal>('/calendario', data);
  return response.data;
}

export async function updateEvento(id: number, data: EventoUpdate): Promise<EventoFiscal> {
  const response = await apiClient.patch<EventoFiscal>(`/calendario/${id}`, data);
  return response.data;
}

export async function deleteEvento(id: number): Promise<void> {
  await apiClient.delete(`/calendario/${id}`);
}

Hooks (use-calendario.ts):

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):

'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 (
    <DashboardShell
      title="Calendario Fiscal"
      description="Obligaciones fiscales y eventos importantes"
    >
      <div className="grid gap-4 lg:grid-cols-3">
        {/* Calendar */}
        <Card className="lg:col-span-2">
          <CardHeader className="flex flex-row items-center justify-between">
            <CardTitle className="flex items-center gap-2">
              <Calendar className="h-5 w-5" />
              {meses[mes - 1]} {año}
            </CardTitle>
            <div className="flex gap-1">
              <Button variant="outline" size="icon" onClick={handlePrevMonth}>
                <ChevronLeft className="h-4 w-4" />
              </Button>
              <Button variant="outline" size="icon" onClick={handleNextMonth}>
                <ChevronRight className="h-4 w-4" />
              </Button>
            </div>
          </CardHeader>
          <CardContent>
            <div className="grid grid-cols-7 gap-1">
              {['Dom', 'Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb'].map(d => (
                <div key={d} className="text-center text-sm font-medium text-muted-foreground py-2">
                  {d}
                </div>
              ))}
              {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 (
                  <div
                    key={i}
                    className={cn(
                      'min-h-[80px] p-1 border rounded-md',
                      day ? 'bg-background' : 'bg-muted/30',
                      isToday && 'ring-2 ring-primary'
                    )}
                  >
                    {day && (
                      <>
                        <div className={cn('text-sm font-medium', isToday && 'text-primary')}>{day}</div>
                        <div className="space-y-1 mt-1">
                          {dayEventos.slice(0, 2).map(e => {
                            const Icon = tipoIcons[e.tipo];
                            return (
                              <div
                                key={e.id}
                                className={cn(
                                  'text-xs px-1 py-0.5 rounded truncate flex items-center gap-1',
                                  tipoColors[e.tipo],
                                  e.completado && 'opacity-50 line-through'
                                )}
                                title={e.titulo}
                              >
                                <Icon className="h-3 w-3 flex-shrink-0" />
                                <span className="truncate">{e.titulo}</span>
                              </div>
                            );
                          })}
                          {dayEventos.length > 2 && (
                            <div className="text-xs text-muted-foreground">+{dayEventos.length - 2} más</div>
                          )}
                        </div>
                      </>
                    )}
                  </div>
                );
              })}
            </div>
          </CardContent>
        </Card>

        {/* Event List */}
        <Card>
          <CardHeader>
            <CardTitle>Eventos del Mes</CardTitle>
          </CardHeader>
          <CardContent>
            {isLoading ? (
              <div className="text-center py-4 text-muted-foreground">Cargando...</div>
            ) : eventos?.length === 0 ? (
              <div className="text-center py-4 text-muted-foreground">No hay eventos este mes</div>
            ) : (
              <div className="space-y-3">
                {eventos?.map(evento => {
                  const Icon = tipoIcons[evento.tipo];
                  return (
                    <div
                      key={evento.id}
                      className={cn(
                        'p-3 rounded-lg border',
                        evento.completado && 'opacity-50'
                      )}
                    >
                      <div className="flex items-start justify-between">
                        <div className="flex items-start gap-2">
                          <div className={cn('p-1.5 rounded', tipoColors[evento.tipo])}>
                            <Icon className="h-4 w-4" />
                          </div>
                          <div>
                            <h4 className={cn('font-medium text-sm', evento.completado && 'line-through')}>
                              {evento.titulo}
                            </h4>
                            <p className="text-xs text-muted-foreground mt-0.5">{evento.descripcion}</p>
                            <p className="text-xs text-muted-foreground mt-1">
                              {new Date(evento.fechaLimite).toLocaleDateString('es-MX', {
                                day: 'numeric',
                                month: 'short',
                              })}
                            </p>
                          </div>
                        </div>
                        <Button
                          variant="ghost"
                          size="icon"
                          className="h-8 w-8"
                          onClick={() => handleToggleComplete(evento.id, evento.completado)}
                        >
                          <Check className={cn('h-4 w-4', evento.completado && 'text-success')} />
                        </Button>
                      </div>
                    </div>
                  );
                })}
              </div>
            )}
          </CardContent>
        </Card>
      </div>
    </DashboardShell>
  );
}

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:

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):

import { prisma } from '../config/database.js';
import bcrypt from 'bcryptjs';
import type { UserListItem, UserInvite, UserUpdate } from '@horux/shared';

export async function getUsuarios(tenantId: string): Promise<UserListItem[]> {
  const users = await prisma.user.findMany({
    where: { tenantId },
    select: {
      id: true,
      email: true,
      nombre: true,
      role: true,
      active: true,
      lastLogin: true,
      createdAt: true,
    },
    orderBy: { createdAt: 'desc' },
  });

  return users.map(u => ({
    ...u,
    lastLogin: u.lastLogin?.toISOString() || null,
    createdAt: u.createdAt.toISOString(),
  }));
}

export async function inviteUsuario(tenantId: string, data: UserInvite): Promise<UserListItem> {
  // Check tenant user limit
  const tenant = await prisma.tenant.findUnique({
    where: { id: tenantId },
    select: { usersLimit: true },
  });

  const currentCount = await prisma.user.count({ where: { tenantId } });

  if (currentCount >= (tenant?.usersLimit || 1)) {
    throw new Error('Límite de usuarios alcanzado para este plan');
  }

  // Generate temporary password
  const tempPassword = Math.random().toString(36).slice(-8);
  const passwordHash = await bcrypt.hash(tempPassword, 12);

  const user = await prisma.user.create({
    data: {
      tenantId,
      email: data.email,
      passwordHash,
      nombre: data.nombre,
      role: data.role,
    },
    select: {
      id: true,
      email: true,
      nombre: true,
      role: true,
      active: true,
      lastLogin: true,
      createdAt: true,
    },
  });

  // In production, send email with tempPassword
  console.log(`Temporary password for ${data.email}: ${tempPassword}`);

  return {
    ...user,
    lastLogin: user.lastLogin?.toISOString() || null,
    createdAt: user.createdAt.toISOString(),
  };
}

export async function updateUsuario(
  tenantId: string,
  userId: string,
  data: UserUpdate
): Promise<UserListItem> {
  const user = await prisma.user.update({
    where: { id: userId, tenantId },
    data: {
      ...(data.nombre && { nombre: data.nombre }),
      ...(data.role && { role: data.role }),
      ...(data.active !== undefined && { active: data.active }),
    },
    select: {
      id: true,
      email: true,
      nombre: true,
      role: true,
      active: true,
      lastLogin: true,
      createdAt: true,
    },
  });

  return {
    ...user,
    lastLogin: user.lastLogin?.toISOString() || null,
    createdAt: user.createdAt.toISOString(),
  };
}

export async function deleteUsuario(tenantId: string, userId: string): Promise<void> {
  await prisma.user.delete({
    where: { id: userId, tenantId },
  });
}

Controller (usuarios.controller.ts):

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):

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:

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):

import { apiClient } from './client';
import type { UserListItem, UserInvite, UserUpdate } from '@horux/shared';

export async function getUsuarios(): Promise<UserListItem[]> {
  const response = await apiClient.get<UserListItem[]>('/usuarios');
  return response.data;
}

export async function inviteUsuario(data: UserInvite): Promise<UserListItem> {
  const response = await apiClient.post<UserListItem>('/usuarios/invite', data);
  return response.data;
}

export async function updateUsuario(id: string, data: UserUpdate): Promise<UserListItem> {
  const response = await apiClient.patch<UserListItem>(`/usuarios/${id}`, data);
  return response.data;
}

export async function deleteUsuario(id: string): Promise<void> {
  await apiClient.delete(`/usuarios/${id}`);
}

Hooks (use-usuarios.ts):

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):

'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 (
    <DashboardShell
      title="Usuarios"
      description="Gestiona los usuarios de tu empresa"
    >
      <div className="space-y-4">
        {/* Header */}
        <div className="flex justify-between items-center">
          <div className="flex items-center gap-2">
            <Users className="h-5 w-5" />
            <span className="font-medium">{usuarios?.length || 0} usuarios</span>
          </div>
          {isAdmin && (
            <Button onClick={() => setShowInvite(true)}>
              <UserPlus className="h-4 w-4 mr-2" />
              Invitar Usuario
            </Button>
          )}
        </div>

        {/* Invite Form */}
        {showInvite && (
          <Card>
            <CardHeader>
              <CardTitle>Invitar Nuevo Usuario</CardTitle>
            </CardHeader>
            <CardContent>
              <form onSubmit={handleInvite} className="space-y-4">
                <div className="grid gap-4 md:grid-cols-3">
                  <div className="space-y-2">
                    <Label htmlFor="email">Email</Label>
                    <Input
                      id="email"
                      type="email"
                      value={inviteForm.email}
                      onChange={e => setInviteForm({ ...inviteForm, email: e.target.value })}
                      required
                    />
                  </div>
                  <div className="space-y-2">
                    <Label htmlFor="nombre">Nombre</Label>
                    <Input
                      id="nombre"
                      value={inviteForm.nombre}
                      onChange={e => setInviteForm({ ...inviteForm, nombre: e.target.value })}
                      required
                    />
                  </div>
                  <div className="space-y-2">
                    <Label htmlFor="role">Rol</Label>
                    <Select
                      value={inviteForm.role}
                      onValueChange={(v: 'admin' | 'contador' | 'visor') => setInviteForm({ ...inviteForm, role: v })}
                    >
                      <SelectTrigger>
                        <SelectValue />
                      </SelectTrigger>
                      <SelectContent>
                        <SelectItem value="admin">Administrador</SelectItem>
                        <SelectItem value="contador">Contador</SelectItem>
                        <SelectItem value="visor">Visor</SelectItem>
                      </SelectContent>
                    </Select>
                  </div>
                </div>
                <div className="flex gap-2">
                  <Button type="submit" disabled={inviteUsuario.isPending}>
                    {inviteUsuario.isPending ? 'Enviando...' : 'Enviar Invitación'}
                  </Button>
                  <Button type="button" variant="outline" onClick={() => setShowInvite(false)}>
                    Cancelar
                  </Button>
                </div>
              </form>
            </CardContent>
          </Card>
        )}

        {/* Users List */}
        <Card>
          <CardContent className="p-0">
            {isLoading ? (
              <div className="text-center py-8 text-muted-foreground">Cargando...</div>
            ) : (
              <div className="divide-y">
                {usuarios?.map(usuario => {
                  const roleInfo = roleLabels[usuario.role];
                  const RoleIcon = roleInfo.icon;
                  const isCurrentUser = usuario.id === currentUser?.id;

                  return (
                    <div key={usuario.id} className="p-4 flex items-center justify-between">
                      <div className="flex items-center gap-4">
                        <div className={cn(
                          'w-10 h-10 rounded-full flex items-center justify-center',
                          'bg-primary/10 text-primary font-medium'
                        )}>
                          {usuario.nombre.charAt(0).toUpperCase()}
                        </div>
                        <div>
                          <div className="flex items-center gap-2">
                            <span className="font-medium">{usuario.nombre}</span>
                            {isCurrentUser && (
                              <span className="text-xs bg-primary/10 text-primary px-2 py-0.5 rounded"></span>
                            )}
                            {!usuario.active && (
                              <span className="text-xs bg-destructive/10 text-destructive px-2 py-0.5 rounded">Inactivo</span>
                            )}
                          </div>
                          <div className="text-sm text-muted-foreground">{usuario.email}</div>
                        </div>
                      </div>
                      <div className="flex items-center gap-4">
                        <div className={cn('flex items-center gap-1', roleInfo.color)}>
                          <RoleIcon className="h-4 w-4" />
                          <span className="text-sm">{roleInfo.label}</span>
                        </div>
                        {isAdmin && !isCurrentUser && (
                          <div className="flex gap-1">
                            <Button
                              variant="ghost"
                              size="sm"
                              onClick={() => handleToggleActive(usuario.id, usuario.active)}
                            >
                              {usuario.active ? 'Desactivar' : 'Activar'}
                            </Button>
                            <Button
                              variant="ghost"
                              size="icon"
                              onClick={() => handleDelete(usuario.id)}
                            >
                              <Trash2 className="h-4 w-4" />
                            </Button>
                          </div>
                        )}
                      </div>
                    </div>
                  );
                })}
              </div>
            )}
          </CardContent>
        </Card>
      </div>
    </DashboardShell>
  );
}

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:

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:

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:

// 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"