Files
Horux360/docs/plans/2026-01-22-fase2-implementation.md
Consultoria AS 72a1e9a39c docs: add Phase 2 implementation plan
- Dashboard with KPIs and charts
- CFDI management (list, filters, pagination)
- IVA/ISR control modules
- Protected dashboard layout
- 15 tasks with complete code

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-22 02:13:52 +00:00

83 KiB

Fase 2: Módulos Core - Implementation Plan

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Implement Dashboard with KPIs, CFDI management, IVA/ISR control, and charts.

Architecture: Backend API endpoints for dashboard data, CFDI CRUD, and tax calculations. Frontend with protected dashboard layout, reusable chart components using Recharts, and data tables with TanStack Table.

Tech Stack: Express + Prisma (backend), Next.js 14 + Recharts + TanStack Table + TanStack Query (frontend)


Task 1: Agregar Dependencias de Fase 2

Files:

  • Modify: apps/web/package.json

Step 1: Agregar dependencias al frontend

{
  "dependencies": {
    "recharts": "^2.12.0",
    "@tanstack/react-table": "^8.20.0",
    "date-fns": "^3.6.0"
  }
}

Step 2: Commit

git add apps/web/package.json
git commit -m "chore: add Phase 2 dependencies (recharts, tanstack-table, date-fns)"

Task 2: Crear Tipos Compartidos para Dashboard y CFDI

Files:

  • Create: packages/shared/src/types/cfdi.ts
  • Create: packages/shared/src/types/dashboard.ts
  • Create: packages/shared/src/types/impuestos.ts
  • Modify: packages/shared/src/index.ts

Step 1: Crear packages/shared/src/types/cfdi.ts

export type TipoCfdi = 'ingreso' | 'egreso' | 'traslado' | 'pago' | 'nomina';
export type EstadoCfdi = 'vigente' | 'cancelado';

export interface Cfdi {
  id: string;
  uuidFiscal: string;
  tipo: TipoCfdi;
  serie: string | null;
  folio: string | null;
  fechaEmision: string;
  fechaTimbrado: string;
  rfcEmisor: string;
  nombreEmisor: string;
  rfcReceptor: string;
  nombreReceptor: string;
  subtotal: number;
  descuento: number;
  iva: number;
  isrRetenido: number;
  ivaRetenido: number;
  total: number;
  moneda: string;
  tipoCambio: number;
  metodoPago: string | null;
  formaPago: string | null;
  usoCfdi: string | null;
  estado: EstadoCfdi;
  xmlUrl: string | null;
  pdfUrl: string | null;
  createdAt: string;
}

export interface CfdiFilters {
  tipo?: TipoCfdi;
  estado?: EstadoCfdi;
  fechaInicio?: string;
  fechaFin?: string;
  rfc?: string;
  search?: string;
  page?: number;
  limit?: number;
}

export interface CfdiListResponse {
  data: Cfdi[];
  total: number;
  page: number;
  limit: number;
  totalPages: number;
}

Step 2: Crear packages/shared/src/types/dashboard.ts

export interface KpiData {
  ingresos: number;
  egresos: number;
  utilidad: number;
  margen: number;
  ivaBalance: number;
  cfdisEmitidos: number;
  cfdisRecibidos: number;
}

export interface IngresosEgresosData {
  mes: string;
  ingresos: number;
  egresos: number;
}

export interface ResumenFiscal {
  ivaPorPagar: number;
  ivaAFavor: number;
  isrPorPagar: number;
  declaracionesPendientes: number;
  proximaObligacion: {
    titulo: string;
    fecha: string;
  } | null;
}

export interface Alerta {
  id: number;
  tipo: 'vencimiento' | 'discrepancia' | 'iva_favor' | 'declaracion';
  titulo: string;
  mensaje: string;
  prioridad: 'alta' | 'media' | 'baja';
  fechaVencimiento: string | null;
  leida: boolean;
  resuelta: boolean;
  createdAt: string;
}

export type PeriodoFiltro = 'semana' | 'mes' | 'trimestre' | 'año' | 'custom';

export interface DashboardFilters {
  periodo: PeriodoFiltro;
  fechaInicio?: string;
  fechaFin?: string;
}

Step 3: Crear packages/shared/src/types/impuestos.ts

export type EstadoDeclaracion = 'pendiente' | 'declarado' | 'acreditado';

export interface IvaMensual {
  id: number;
  año: number;
  mes: number;
  ivaTrasladado: number;
  ivaAcreditable: number;
  ivaRetenido: number;
  resultado: number;
  acumulado: number;
  estado: EstadoDeclaracion;
  fechaDeclaracion: string | null;
}

export interface IsrMensual {
  id: number;
  año: number;
  mes: number;
  ingresosAcumulados: number;
  deducciones: number;
  baseGravable: number;
  isrCausado: number;
  isrRetenido: number;
  isrAPagar: number;
  estado: EstadoDeclaracion;
  fechaDeclaracion: string | null;
}

export interface ResumenIva {
  trasladado: number;
  acreditable: number;
  retenido: number;
  resultado: number;
  acumuladoAnual: number;
}

export interface ResumenIsr {
  ingresosAcumulados: number;
  deducciones: number;
  baseGravable: number;
  isrCausado: number;
  isrRetenido: number;
  isrAPagar: number;
}

Step 4: Actualizar packages/shared/src/index.ts

// Types
export * from './types/auth';
export * from './types/tenant';
export * from './types/user';
export * from './types/cfdi';
export * from './types/dashboard';
export * from './types/impuestos';

// Constants
export * from './constants/plans';
export * from './constants/roles';

Step 5: Commit

git add packages/shared/src/
git commit -m "feat(shared): add types for dashboard, cfdi, and impuestos"

Task 3: Crear Middleware de Tenant

Files:

  • Create: apps/api/src/middlewares/tenant.middleware.ts

Step 1: Crear middleware

import type { Request, Response, NextFunction } from 'express';
import { prisma } from '../config/database.js';
import { AppError } from './error.middleware.js';

declare global {
  namespace Express {
    interface Request {
      tenantSchema?: string;
    }
  }
}

export async function tenantMiddleware(req: Request, res: Response, next: NextFunction) {
  if (!req.user) {
    return next(new AppError(401, 'No autenticado'));
  }

  try {
    const tenant = await prisma.tenant.findUnique({
      where: { id: req.user.tenantId },
      select: { schemaName: true, active: true },
    });

    if (!tenant || !tenant.active) {
      return next(new AppError(403, 'Tenant no encontrado o inactivo'));
    }

    req.tenantSchema = tenant.schemaName;

    // Set search_path for this request
    await prisma.$executeRawUnsafe(`SET search_path TO "${tenant.schemaName}", public`);

    next();
  } catch (error) {
    next(new AppError(500, 'Error al configurar tenant'));
  }
}

Step 2: Commit

git add apps/api/src/middlewares/tenant.middleware.ts
git commit -m "feat(api): add tenant middleware for multi-tenant schema isolation"

Task 4: Crear API de Dashboard

Files:

  • Create: apps/api/src/services/dashboard.service.ts
  • Create: apps/api/src/controllers/dashboard.controller.ts
  • Create: apps/api/src/routes/dashboard.routes.ts
  • Modify: apps/api/src/app.ts

Step 1: Crear apps/api/src/services/dashboard.service.ts

import { prisma } from '../config/database.js';
import type { KpiData, IngresosEgresosData, ResumenFiscal, Alerta } from '@horux/shared';

export async function getKpis(schema: string, año: number, mes: number): Promise<KpiData> {
  const [ingresos] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
    SELECT COALESCE(SUM(total), 0) as total
    FROM "${schema}".cfdis
    WHERE tipo = 'ingreso'
    AND estado = 'vigente'
    AND EXTRACT(YEAR FROM fecha_emision) = $1
    AND EXTRACT(MONTH FROM fecha_emision) = $2
  `, año, mes);

  const [egresos] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
    SELECT COALESCE(SUM(total), 0) as total
    FROM "${schema}".cfdis
    WHERE tipo = 'egreso'
    AND estado = 'vigente'
    AND EXTRACT(YEAR FROM fecha_emision) = $1
    AND EXTRACT(MONTH FROM fecha_emision) = $2
  `, año, mes);

  const [ivaData] = await prisma.$queryRawUnsafe<[{ trasladado: number; acreditable: number }]>(`
    SELECT
      COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) as trasladado,
      COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as acreditable
    FROM "${schema}".cfdis
    WHERE estado = 'vigente'
    AND EXTRACT(YEAR FROM fecha_emision) = $1
    AND EXTRACT(MONTH FROM fecha_emision) = $2
  `, año, mes);

  const [counts] = await prisma.$queryRawUnsafe<[{ emitidos: number; recibidos: number }]>(`
    SELECT
      COUNT(CASE WHEN tipo = 'ingreso' THEN 1 END) as emitidos,
      COUNT(CASE WHEN tipo = 'egreso' THEN 1 END) as recibidos
    FROM "${schema}".cfdis
    WHERE estado = 'vigente'
    AND EXTRACT(YEAR FROM fecha_emision) = $1
    AND EXTRACT(MONTH FROM fecha_emision) = $2
  `, año, mes);

  const ingresosVal = Number(ingresos?.total || 0);
  const egresosVal = Number(egresos?.total || 0);
  const utilidad = ingresosVal - egresosVal;
  const margen = ingresosVal > 0 ? (utilidad / ingresosVal) * 100 : 0;
  const ivaBalance = Number(ivaData?.trasladado || 0) - Number(ivaData?.acreditable || 0);

  return {
    ingresos: ingresosVal,
    egresos: egresosVal,
    utilidad,
    margen: Math.round(margen * 100) / 100,
    ivaBalance,
    cfdisEmitidos: Number(counts?.emitidos || 0),
    cfdisRecibidos: Number(counts?.recibidos || 0),
  };
}

export async function getIngresosEgresos(schema: string, año: number): Promise<IngresosEgresosData[]> {
  const data = 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 EXTRACT(MONTH FROM fecha_emision)
    ORDER BY mes
  `, año);

  const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];

  return meses.map((mes, index) => {
    const found = data.find(d => d.mes === index + 1);
    return {
      mes,
      ingresos: Number(found?.ingresos || 0),
      egresos: Number(found?.egresos || 0),
    };
  });
}

export async function getResumenFiscal(schema: string, año: number, mes: number): Promise<ResumenFiscal> {
  const [ivaResult] = await prisma.$queryRawUnsafe<[{ resultado: number; acumulado: number }]>(`
    SELECT resultado, acumulado FROM "${schema}".iva_mensual
    WHERE año = $1 AND mes = $2
  `, año, mes) || [{ resultado: 0, acumulado: 0 }];

  const [pendientes] = await prisma.$queryRawUnsafe<[{ count: number }]>(`
    SELECT COUNT(*) as count FROM "${schema}".iva_mensual
    WHERE año = $1 AND estado = 'pendiente'
  `, año);

  const resultado = Number(ivaResult?.resultado || 0);
  const acumulado = Number(ivaResult?.acumulado || 0);

  return {
    ivaPorPagar: resultado > 0 ? resultado : 0,
    ivaAFavor: acumulado < 0 ? Math.abs(acumulado) : 0,
    isrPorPagar: 0,
    declaracionesPendientes: Number(pendientes?.count || 0),
    proximaObligacion: {
      titulo: 'Declaración mensual IVA/ISR',
      fecha: new Date(año, mes, 17).toISOString(),
    },
  };
}

export async function getAlertas(schema: string, limit = 5): Promise<Alerta[]> {
  const alertas = await prisma.$queryRawUnsafe<Alerta[]>(`
    SELECT id, tipo, titulo, mensaje, prioridad,
           fecha_vencimiento as "fechaVencimiento",
           leida, resuelta,
           created_at as "createdAt"
    FROM "${schema}".alertas
    WHERE resuelta = false
    ORDER BY
      CASE prioridad WHEN 'alta' THEN 1 WHEN 'media' THEN 2 ELSE 3 END,
      created_at DESC
    LIMIT $1
  `, limit);

  return alertas;
}

Step 2: Crear apps/api/src/controllers/dashboard.controller.ts

import type { Request, Response, NextFunction } from 'express';
import * as dashboardService from '../services/dashboard.service.js';
import { AppError } from '../middlewares/error.middleware.js';

export async function getKpis(req: Request, res: Response, next: NextFunction) {
  try {
    if (!req.tenantSchema) {
      return next(new AppError(400, 'Schema no configurado'));
    }

    const año = parseInt(req.query.año as string) || new Date().getFullYear();
    const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1;

    const kpis = await dashboardService.getKpis(req.tenantSchema, año, mes);
    res.json(kpis);
  } catch (error) {
    next(error);
  }
}

export async function getIngresosEgresos(req: Request, res: Response, next: NextFunction) {
  try {
    if (!req.tenantSchema) {
      return next(new AppError(400, 'Schema no configurado'));
    }

    const año = parseInt(req.query.año as string) || new Date().getFullYear();

    const data = await dashboardService.getIngresosEgresos(req.tenantSchema, año);
    res.json(data);
  } catch (error) {
    next(error);
  }
}

export async function getResumenFiscal(req: Request, res: Response, next: NextFunction) {
  try {
    if (!req.tenantSchema) {
      return next(new AppError(400, 'Schema no configurado'));
    }

    const año = parseInt(req.query.año as string) || new Date().getFullYear();
    const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1;

    const resumen = await dashboardService.getResumenFiscal(req.tenantSchema, año, mes);
    res.json(resumen);
  } catch (error) {
    next(error);
  }
}

export async function getAlertas(req: Request, res: Response, next: NextFunction) {
  try {
    if (!req.tenantSchema) {
      return next(new AppError(400, 'Schema no configurado'));
    }

    const limit = parseInt(req.query.limit as string) || 5;

    const alertas = await dashboardService.getAlertas(req.tenantSchema, limit);
    res.json(alertas);
  } catch (error) {
    next(error);
  }
}

Step 3: Crear apps/api/src/routes/dashboard.routes.ts

import { Router } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import * as dashboardController from '../controllers/dashboard.controller.js';

const router = Router();

router.use(authenticate);
router.use(tenantMiddleware);

router.get('/kpis', dashboardController.getKpis);
router.get('/ingresos-egresos', dashboardController.getIngresosEgresos);
router.get('/resumen-fiscal', dashboardController.getResumenFiscal);
router.get('/alertas', dashboardController.getAlertas);

export { router as dashboardRoutes };

Step 4: Actualizar apps/api/src/app.ts

import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import { env } from './config/env.js';
import { errorMiddleware } from './middlewares/error.middleware.js';
import { authRoutes } from './routes/auth.routes.js';
import { dashboardRoutes } from './routes/dashboard.routes.js';

const app = express();

// Security
app.use(helmet());
app.use(cors({
  origin: env.CORS_ORIGIN,
  credentials: true,
}));

// Body parsing
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// Health check
app.get('/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

// API Routes
app.use('/api/auth', authRoutes);
app.use('/api/dashboard', dashboardRoutes);

// Error handling
app.use(errorMiddleware);

export { app };

Step 5: Commit

git add apps/api/src/
git commit -m "feat(api): add dashboard API endpoints (kpis, ingresos-egresos, resumen-fiscal, alertas)"

Task 5: Crear API de CFDI

Files:

  • Create: apps/api/src/services/cfdi.service.ts
  • Create: apps/api/src/controllers/cfdi.controller.ts
  • Create: apps/api/src/routes/cfdi.routes.ts
  • Modify: apps/api/src/app.ts

Step 1: Crear apps/api/src/services/cfdi.service.ts

import { prisma } from '../config/database.js';
import type { Cfdi, CfdiFilters, CfdiListResponse } from '@horux/shared';

export async function getCfdis(schema: string, filters: CfdiFilters): Promise<CfdiListResponse> {
  const page = filters.page || 1;
  const limit = filters.limit || 20;
  const offset = (page - 1) * limit;

  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);
  }

  if (filters.rfc) {
    whereClause += ` AND (rfc_emisor ILIKE $${paramIndex} OR rfc_receptor ILIKE $${paramIndex++})`;
    params.push(`%${filters.rfc}%`);
  }

  if (filters.search) {
    whereClause += ` AND (uuid_fiscal ILIKE $${paramIndex} OR nombre_emisor ILIKE $${paramIndex} OR nombre_receptor ILIKE $${paramIndex++})`;
    params.push(`%${filters.search}%`);
  }

  const countResult = await prisma.$queryRawUnsafe<[{ count: number }]>(`
    SELECT COUNT(*) as count FROM "${schema}".cfdis ${whereClause}
  `, ...params);

  const total = Number(countResult[0]?.count || 0);

  params.push(limit, offset);
  const data = await prisma.$queryRawUnsafe<Cfdi[]>(`
    SELECT
      id, uuid_fiscal as "uuidFiscal", tipo, serie, folio,
      fecha_emision as "fechaEmision", fecha_timbrado as "fechaTimbrado",
      rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor",
      rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor",
      subtotal, descuento, iva, isr_retenido as "isrRetenido",
      iva_retenido as "ivaRetenido", total, moneda,
      tipo_cambio as "tipoCambio", metodo_pago as "metodoPago",
      forma_pago as "formaPago", uso_cfdi as "usoCfdi",
      estado, xml_url as "xmlUrl", pdf_url as "pdfUrl",
      created_at as "createdAt"
    FROM "${schema}".cfdis
    ${whereClause}
    ORDER BY fecha_emision DESC
    LIMIT $${paramIndex++} OFFSET $${paramIndex}
  `, ...params);

  return {
    data,
    total,
    page,
    limit,
    totalPages: Math.ceil(total / limit),
  };
}

export async function getCfdiById(schema: string, id: string): Promise<Cfdi | null> {
  const result = await prisma.$queryRawUnsafe<Cfdi[]>(`
    SELECT
      id, uuid_fiscal as "uuidFiscal", tipo, serie, folio,
      fecha_emision as "fechaEmision", fecha_timbrado as "fechaTimbrado",
      rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor",
      rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor",
      subtotal, descuento, iva, isr_retenido as "isrRetenido",
      iva_retenido as "ivaRetenido", total, moneda,
      tipo_cambio as "tipoCambio", metodo_pago as "metodoPago",
      forma_pago as "formaPago", uso_cfdi as "usoCfdi",
      estado, xml_url as "xmlUrl", pdf_url as "pdfUrl",
      created_at as "createdAt"
    FROM "${schema}".cfdis
    WHERE id = $1
  `, id);

  return result[0] || null;
}

export async function getResumenCfdis(schema: string, año: number, mes: number) {
  const result = await prisma.$queryRawUnsafe<[{
    total_ingresos: number;
    total_egresos: number;
    count_ingresos: number;
    count_egresos: number;
    iva_trasladado: number;
    iva_acreditable: number;
  }]>(`
    SELECT
      COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN total ELSE 0 END), 0) as total_ingresos,
      COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as total_egresos,
      COUNT(CASE WHEN tipo = 'ingreso' THEN 1 END) as count_ingresos,
      COUNT(CASE WHEN tipo = 'egreso' THEN 1 END) as count_egresos,
      COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) as iva_trasladado,
      COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as iva_acreditable
    FROM "${schema}".cfdis
    WHERE estado = 'vigente'
    AND EXTRACT(YEAR FROM fecha_emision) = $1
    AND EXTRACT(MONTH FROM fecha_emision) = $2
  `, año, mes);

  const r = result[0];
  return {
    totalIngresos: Number(r?.total_ingresos || 0),
    totalEgresos: Number(r?.total_egresos || 0),
    countIngresos: Number(r?.count_ingresos || 0),
    countEgresos: Number(r?.count_egresos || 0),
    ivaTrasladado: Number(r?.iva_trasladado || 0),
    ivaAcreditable: Number(r?.iva_acreditable || 0),
  };
}

Step 2: Crear apps/api/src/controllers/cfdi.controller.ts

import type { Request, Response, NextFunction } from 'express';
import * as cfdiService from '../services/cfdi.service.js';
import { AppError } from '../middlewares/error.middleware.js';
import type { CfdiFilters } from '@horux/shared';

export async function getCfdis(req: Request, res: Response, next: NextFunction) {
  try {
    if (!req.tenantSchema) {
      return next(new AppError(400, 'Schema no configurado'));
    }

    const filters: CfdiFilters = {
      tipo: req.query.tipo as any,
      estado: req.query.estado as any,
      fechaInicio: req.query.fechaInicio as string,
      fechaFin: req.query.fechaFin as string,
      rfc: req.query.rfc as string,
      search: req.query.search as string,
      page: parseInt(req.query.page as string) || 1,
      limit: parseInt(req.query.limit as string) || 20,
    };

    const result = await cfdiService.getCfdis(req.tenantSchema, filters);
    res.json(result);
  } catch (error) {
    next(error);
  }
}

export async function getCfdiById(req: Request, res: Response, next: NextFunction) {
  try {
    if (!req.tenantSchema) {
      return next(new AppError(400, 'Schema no configurado'));
    }

    const cfdi = await cfdiService.getCfdiById(req.tenantSchema, req.params.id);

    if (!cfdi) {
      return next(new AppError(404, 'CFDI no encontrado'));
    }

    res.json(cfdi);
  } catch (error) {
    next(error);
  }
}

export async function getResumen(req: Request, res: Response, next: NextFunction) {
  try {
    if (!req.tenantSchema) {
      return next(new AppError(400, 'Schema no configurado'));
    }

    const año = parseInt(req.query.año as string) || new Date().getFullYear();
    const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1;

    const resumen = await cfdiService.getResumenCfdis(req.tenantSchema, año, mes);
    res.json(resumen);
  } catch (error) {
    next(error);
  }
}

Step 3: Crear apps/api/src/routes/cfdi.routes.ts

import { Router } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import * as cfdiController from '../controllers/cfdi.controller.js';

const router = Router();

router.use(authenticate);
router.use(tenantMiddleware);

router.get('/', cfdiController.getCfdis);
router.get('/resumen', cfdiController.getResumen);
router.get('/:id', cfdiController.getCfdiById);

export { router as cfdiRoutes };

Step 4: Actualizar apps/api/src/app.ts para agregar rutas CFDI

import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import { env } from './config/env.js';
import { errorMiddleware } from './middlewares/error.middleware.js';
import { authRoutes } from './routes/auth.routes.js';
import { dashboardRoutes } from './routes/dashboard.routes.js';
import { cfdiRoutes } from './routes/cfdi.routes.js';

const app = express();

// Security
app.use(helmet());
app.use(cors({
  origin: env.CORS_ORIGIN,
  credentials: true,
}));

// Body parsing
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// Health check
app.get('/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

// API Routes
app.use('/api/auth', authRoutes);
app.use('/api/dashboard', dashboardRoutes);
app.use('/api/cfdi', cfdiRoutes);

// Error handling
app.use(errorMiddleware);

export { app };

Step 5: Commit

git add apps/api/src/
git commit -m "feat(api): add CFDI API endpoints (list, detail, resumen)"

Task 6: Crear API de Impuestos (IVA/ISR)

Files:

  • Create: apps/api/src/services/impuestos.service.ts
  • Create: apps/api/src/controllers/impuestos.controller.ts
  • Create: apps/api/src/routes/impuestos.routes.ts
  • Modify: apps/api/src/app.ts

Step 1: Crear apps/api/src/services/impuestos.service.ts

import { prisma } from '../config/database.js';
import type { IvaMensual, IsrMensual, ResumenIva, ResumenIsr } from '@horux/shared';

export async function getIvaMensual(schema: string, año: number): Promise<IvaMensual[]> {
  const data = await prisma.$queryRawUnsafe<IvaMensual[]>(`
    SELECT
      id, año, mes,
      iva_trasladado as "ivaTrasladado",
      iva_acreditable as "ivaAcreditable",
      COALESCE(iva_retenido, 0) as "ivaRetenido",
      resultado, acumulado, estado,
      fecha_declaracion as "fechaDeclaracion"
    FROM "${schema}".iva_mensual
    WHERE año = $1
    ORDER BY mes
  `, año);

  return data.map(row => ({
    ...row,
    ivaTrasladado: Number(row.ivaTrasladado),
    ivaAcreditable: Number(row.ivaAcreditable),
    ivaRetenido: Number(row.ivaRetenido),
    resultado: Number(row.resultado),
    acumulado: Number(row.acumulado),
  }));
}

export async function getResumenIva(schema: string, año: number, mes: number): Promise<ResumenIva> {
  // Get from iva_mensual if exists
  const [existing] = await prisma.$queryRawUnsafe<IvaMensual[]>(`
    SELECT * FROM "${schema}".iva_mensual WHERE año = $1 AND mes = $2
  `, año, mes);

  if (existing) {
    const [acumuladoResult] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
      SELECT COALESCE(SUM(resultado), 0) as total
      FROM "${schema}".iva_mensual
      WHERE año = $1 AND mes <= $2
    `, año, mes);

    return {
      trasladado: Number(existing.ivaTrasladado || existing.iva_trasladado),
      acreditable: Number(existing.ivaAcreditable || existing.iva_acreditable),
      retenido: Number(existing.ivaRetenido || existing.iva_retenido || 0),
      resultado: Number(existing.resultado),
      acumuladoAnual: Number(acumuladoResult?.total || 0),
    };
  }

  // Calculate from CFDIs if no iva_mensual record
  const [calcResult] = await prisma.$queryRawUnsafe<[{
    trasladado: number;
    acreditable: number;
    retenido: number;
  }]>(`
    SELECT
      COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) as trasladado,
      COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as acreditable,
      COALESCE(SUM(iva_retenido), 0) as retenido
    FROM "${schema}".cfdis
    WHERE estado = 'vigente'
    AND EXTRACT(YEAR FROM fecha_emision) = $1
    AND EXTRACT(MONTH FROM fecha_emision) = $2
  `, año, mes);

  const trasladado = Number(calcResult?.trasladado || 0);
  const acreditable = Number(calcResult?.acreditable || 0);
  const retenido = Number(calcResult?.retenido || 0);
  const resultado = trasladado - acreditable - retenido;

  return {
    trasladado,
    acreditable,
    retenido,
    resultado,
    acumuladoAnual: resultado,
  };
}

export async function getIsrMensual(schema: string, año: number): Promise<IsrMensual[]> {
  // Check if isr_mensual table exists
  try {
    const data = await prisma.$queryRawUnsafe<IsrMensual[]>(`
      SELECT
        id, año, mes,
        ingresos_acumulados as "ingresosAcumulados",
        deducciones,
        base_gravable as "baseGravable",
        isr_causado as "isrCausado",
        isr_retenido as "isrRetenido",
        isr_a_pagar as "isrAPagar",
        estado,
        fecha_declaracion as "fechaDeclaracion"
      FROM "${schema}".isr_mensual
      WHERE año = $1
      ORDER BY mes
    `, año);

    return data.map(row => ({
      ...row,
      ingresosAcumulados: Number(row.ingresosAcumulados),
      deducciones: Number(row.deducciones),
      baseGravable: Number(row.baseGravable),
      isrCausado: Number(row.isrCausado),
      isrRetenido: Number(row.isrRetenido),
      isrAPagar: Number(row.isrAPagar),
    }));
  } catch {
    // Table doesn't exist, return empty array
    return [];
  }
}

export async function getResumenIsr(schema: string, año: number, mes: number): Promise<ResumenIsr> {
  // Calculate from CFDIs
  const [ingresos] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
    SELECT COALESCE(SUM(total), 0) as total
    FROM "${schema}".cfdis
    WHERE tipo = 'ingreso' AND estado = 'vigente'
    AND EXTRACT(YEAR FROM fecha_emision) = $1
    AND EXTRACT(MONTH FROM fecha_emision) <= $2
  `, año, mes);

  const [egresos] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
    SELECT COALESCE(SUM(total), 0) as total
    FROM "${schema}".cfdis
    WHERE tipo = 'egreso' AND estado = 'vigente'
    AND EXTRACT(YEAR FROM fecha_emision) = $1
    AND EXTRACT(MONTH FROM fecha_emision) <= $2
  `, año, mes);

  const [retenido] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
    SELECT COALESCE(SUM(isr_retenido), 0) as total
    FROM "${schema}".cfdis
    WHERE estado = 'vigente'
    AND EXTRACT(YEAR FROM fecha_emision) = $1
    AND EXTRACT(MONTH FROM fecha_emision) <= $2
  `, año, mes);

  const ingresosAcumulados = Number(ingresos?.total || 0);
  const deducciones = Number(egresos?.total || 0);
  const baseGravable = Math.max(0, ingresosAcumulados - deducciones);

  // Simplified ISR calculation (actual calculation would use SAT tables)
  const isrCausado = baseGravable * 0.30; // 30% simplified rate
  const isrRetenido = Number(retenido?.total || 0);
  const isrAPagar = Math.max(0, isrCausado - isrRetenido);

  return {
    ingresosAcumulados,
    deducciones,
    baseGravable,
    isrCausado,
    isrRetenido,
    isrAPagar,
  };
}

Step 2: Crear apps/api/src/controllers/impuestos.controller.ts

import type { Request, Response, NextFunction } from 'express';
import * as impuestosService from '../services/impuestos.service.js';
import { AppError } from '../middlewares/error.middleware.js';

export async function getIvaMensual(req: Request, res: Response, next: NextFunction) {
  try {
    if (!req.tenantSchema) {
      return next(new AppError(400, 'Schema no configurado'));
    }

    const año = parseInt(req.query.año as string) || new Date().getFullYear();
    const data = await impuestosService.getIvaMensual(req.tenantSchema, año);
    res.json(data);
  } catch (error) {
    next(error);
  }
}

export async function getResumenIva(req: Request, res: Response, next: NextFunction) {
  try {
    if (!req.tenantSchema) {
      return next(new AppError(400, 'Schema no configurado'));
    }

    const año = parseInt(req.query.año as string) || new Date().getFullYear();
    const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1;

    const resumen = await impuestosService.getResumenIva(req.tenantSchema, año, mes);
    res.json(resumen);
  } catch (error) {
    next(error);
  }
}

export async function getIsrMensual(req: Request, res: Response, next: NextFunction) {
  try {
    if (!req.tenantSchema) {
      return next(new AppError(400, 'Schema no configurado'));
    }

    const año = parseInt(req.query.año as string) || new Date().getFullYear();
    const data = await impuestosService.getIsrMensual(req.tenantSchema, año);
    res.json(data);
  } catch (error) {
    next(error);
  }
}

export async function getResumenIsr(req: Request, res: Response, next: NextFunction) {
  try {
    if (!req.tenantSchema) {
      return next(new AppError(400, 'Schema no configurado'));
    }

    const año = parseInt(req.query.año as string) || new Date().getFullYear();
    const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1;

    const resumen = await impuestosService.getResumenIsr(req.tenantSchema, año, mes);
    res.json(resumen);
  } catch (error) {
    next(error);
  }
}

Step 3: Crear apps/api/src/routes/impuestos.routes.ts

import { Router } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import * as impuestosController from '../controllers/impuestos.controller.js';

const router = Router();

router.use(authenticate);
router.use(tenantMiddleware);

router.get('/iva/mensual', impuestosController.getIvaMensual);
router.get('/iva/resumen', impuestosController.getResumenIva);
router.get('/isr/mensual', impuestosController.getIsrMensual);
router.get('/isr/resumen', impuestosController.getResumenIsr);

export { router as impuestosRoutes };

Step 4: Actualizar apps/api/src/app.ts

import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import { env } from './config/env.js';
import { errorMiddleware } from './middlewares/error.middleware.js';
import { authRoutes } from './routes/auth.routes.js';
import { dashboardRoutes } from './routes/dashboard.routes.js';
import { cfdiRoutes } from './routes/cfdi.routes.js';
import { impuestosRoutes } from './routes/impuestos.routes.js';

const app = express();

// Security
app.use(helmet());
app.use(cors({
  origin: env.CORS_ORIGIN,
  credentials: true,
}));

// Body parsing
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// Health check
app.get('/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

// API Routes
app.use('/api/auth', authRoutes);
app.use('/api/dashboard', dashboardRoutes);
app.use('/api/cfdi', cfdiRoutes);
app.use('/api/impuestos', impuestosRoutes);

// Error handling
app.use(errorMiddleware);

export { app };

Step 5: Commit

git add apps/api/src/
git commit -m "feat(api): add impuestos API endpoints (IVA/ISR mensual y resumen)"

Task 7: Crear Layout del Dashboard (Frontend)

Files:

  • Create: apps/web/app/(dashboard)/layout.tsx
  • Create: apps/web/components/layouts/sidebar.tsx
  • Create: apps/web/components/layouts/header.tsx
  • Create: apps/web/components/layouts/dashboard-shell.tsx

Step 1: Crear apps/web/components/layouts/sidebar.tsx

'use client';

import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
import {
  LayoutDashboard,
  FileText,
  Calculator,
  Settings,
  LogOut,
} from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store';
import { logout } from '@/lib/api/auth';
import { useRouter } from 'next/navigation';

const navigation = [
  { name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
  { name: 'CFDI', href: '/cfdi', icon: FileText },
  { name: 'Impuestos', href: '/impuestos', icon: Calculator },
  { name: 'Configuración', href: '/configuracion', icon: Settings },
];

export function Sidebar() {
  const pathname = usePathname();
  const router = useRouter();
  const { user, logout: clearAuth } = useAuthStore();

  const handleLogout = async () => {
    try {
      await logout();
    } catch {
      // Ignore errors
    } finally {
      clearAuth();
      router.push('/login');
    }
  };

  return (
    <aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r bg-card">
      <div className="flex h-full flex-col">
        {/* Logo */}
        <div className="flex h-16 items-center border-b px-6">
          <Link href="/dashboard" className="flex items-center gap-2">
            <div className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center">
              <span className="text-primary-foreground font-bold text-lg">H</span>
            </div>
            <span className="font-bold text-xl">Horux360</span>
          </Link>
        </div>

        {/* Navigation */}
        <nav className="flex-1 space-y-1 px-3 py-4">
          {navigation.map((item) => {
            const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
            return (
              <Link
                key={item.name}
                href={item.href}
                className={cn(
                  'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
                  isActive
                    ? 'bg-primary text-primary-foreground'
                    : 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
                )}
              >
                <item.icon className="h-5 w-5" />
                {item.name}
              </Link>
            );
          })}
        </nav>

        {/* User & Logout */}
        <div className="border-t p-4">
          <div className="mb-3 px-3">
            <p className="text-sm font-medium">{user?.nombre}</p>
            <p className="text-xs text-muted-foreground">{user?.email}</p>
          </div>
          <button
            onClick={handleLogout}
            className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-destructive hover:text-destructive-foreground transition-colors"
          >
            <LogOut className="h-5 w-5" />
            Cerrar sesión
          </button>
        </div>
      </div>
    </aside>
  );
}

Step 2: Crear apps/web/components/layouts/header.tsx

'use client';

import { useThemeStore } from '@/stores/theme-store';
import { themes, type ThemeName } from '@/themes';
import { Button } from '@/components/ui/button';
import { Sun, Moon, Palette } from 'lucide-react';

const themeIcons: Record<ThemeName, React.ReactNode> = {
  light: <Sun className="h-4 w-4" />,
  vibrant: <Palette className="h-4 w-4" />,
  corporate: <Palette className="h-4 w-4" />,
  dark: <Moon className="h-4 w-4" />,
};

const themeOrder: ThemeName[] = ['light', 'vibrant', 'corporate', 'dark'];

export function Header({ title }: { title: string }) {
  const { theme, setTheme } = useThemeStore();

  const cycleTheme = () => {
    const currentIndex = themeOrder.indexOf(theme);
    const nextIndex = (currentIndex + 1) % themeOrder.length;
    setTheme(themeOrder[nextIndex]);
  };

  return (
    <header className="sticky top-0 z-30 flex h-16 items-center justify-between border-b bg-background/95 backdrop-blur px-6">
      <h1 className="text-xl font-semibold">{title}</h1>

      <div className="flex items-center gap-2">
        <Button
          variant="ghost"
          size="icon"
          onClick={cycleTheme}
          title={`Tema: ${themes[theme].name}`}
        >
          {themeIcons[theme]}
        </Button>
      </div>
    </header>
  );
}

Step 3: Crear apps/web/components/layouts/dashboard-shell.tsx

import { Sidebar } from './sidebar';
import { Header } from './header';

interface DashboardShellProps {
  children: React.ReactNode;
  title: string;
}

export function DashboardShell({ children, title }: DashboardShellProps) {
  return (
    <div className="min-h-screen bg-background">
      <Sidebar />
      <div className="pl-64">
        <Header title={title} />
        <main className="p-6">{children}</main>
      </div>
    </div>
  );
}

Step 4: Crear apps/web/app/(dashboard)/layout.tsx

'use client';

import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/auth-store';
import { Sidebar } from '@/components/layouts/sidebar';

export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const router = useRouter();
  const { isAuthenticated } = useAuthStore();

  useEffect(() => {
    if (!isAuthenticated) {
      router.push('/login');
    }
  }, [isAuthenticated, router]);

  if (!isAuthenticated) {
    return null;
  }

  return (
    <div className="min-h-screen bg-background">
      <Sidebar />
      <div className="pl-64">
        {children}
      </div>
    </div>
  );
}

Step 5: Commit

git add apps/web/components/layouts apps/web/app/\(dashboard\)/layout.tsx
git commit -m "feat(web): add dashboard layout with sidebar and header"

Task 8: Crear Componentes de Gráficos

Files:

  • Create: apps/web/components/charts/bar-chart.tsx
  • Create: apps/web/components/charts/kpi-card.tsx
  • Create: apps/web/components/charts/index.ts

Step 1: Crear apps/web/components/charts/kpi-card.tsx

import { Card, CardContent } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';

interface KpiCardProps {
  title: string;
  value: string | number;
  subtitle?: string;
  trend?: 'up' | 'down' | 'neutral';
  trendValue?: string;
  icon?: React.ReactNode;
  className?: string;
}

export function KpiCard({
  title,
  value,
  subtitle,
  trend,
  trendValue,
  icon,
  className,
}: KpiCardProps) {
  const formatValue = (val: string | number) => {
    if (typeof val === 'number') {
      return new Intl.NumberFormat('es-MX', {
        style: 'currency',
        currency: 'MXN',
        minimumFractionDigits: 0,
        maximumFractionDigits: 0,
      }).format(val);
    }
    return val;
  };

  return (
    <Card className={cn('', className)}>
      <CardContent className="p-6">
        <div className="flex items-center justify-between">
          <p className="text-sm font-medium text-muted-foreground">{title}</p>
          {icon && <div className="text-muted-foreground">{icon}</div>}
        </div>
        <div className="mt-2">
          <p className="text-2xl font-bold">{formatValue(value)}</p>
          {(subtitle || trend) && (
            <div className="mt-1 flex items-center gap-2">
              {trend && (
                <span
                  className={cn(
                    'flex items-center text-xs font-medium',
                    trend === 'up' && 'text-success',
                    trend === 'down' && 'text-destructive',
                    trend === 'neutral' && 'text-muted-foreground'
                  )}
                >
                  {trend === 'up' && <TrendingUp className="mr-1 h-3 w-3" />}
                  {trend === 'down' && <TrendingDown className="mr-1 h-3 w-3" />}
                  {trend === 'neutral' && <Minus className="mr-1 h-3 w-3" />}
                  {trendValue}
                </span>
              )}
              {subtitle && (
                <span className="text-xs text-muted-foreground">{subtitle}</span>
              )}
            </div>
          )}
        </div>
      </CardContent>
    </Card>
  );
}

Step 2: Crear apps/web/components/charts/bar-chart.tsx

'use client';

import {
  BarChart as RechartsBarChart,
  Bar,
  XAxis,
  YAxis,
  CartesianGrid,
  Tooltip,
  ResponsiveContainer,
  Legend,
} from 'recharts';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';

interface BarChartProps {
  title: string;
  data: { mes: string; ingresos: number; egresos: number }[];
}

const formatCurrency = (value: number) => {
  if (value >= 1000000) {
    return `$${(value / 1000000).toFixed(1)}M`;
  }
  if (value >= 1000) {
    return `$${(value / 1000).toFixed(0)}K`;
  }
  return `$${value}`;
};

export function BarChart({ title, data }: BarChartProps) {
  return (
    <Card>
      <CardHeader>
        <CardTitle className="text-base font-medium">{title}</CardTitle>
      </CardHeader>
      <CardContent>
        <div className="h-[300px]">
          <ResponsiveContainer width="100%" height="100%">
            <RechartsBarChart data={data} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
              <CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
              <XAxis
                dataKey="mes"
                tick={{ fontSize: 12 }}
                tickLine={false}
                axisLine={false}
                className="text-muted-foreground"
              />
              <YAxis
                tickFormatter={formatCurrency}
                tick={{ fontSize: 12 }}
                tickLine={false}
                axisLine={false}
                className="text-muted-foreground"
              />
              <Tooltip
                formatter={(value: number) =>
                  new Intl.NumberFormat('es-MX', {
                    style: 'currency',
                    currency: 'MXN',
                  }).format(value)
                }
                contentStyle={{
                  backgroundColor: 'hsl(var(--card))',
                  border: '1px solid hsl(var(--border))',
                  borderRadius: '8px',
                }}
              />
              <Legend />
              <Bar
                dataKey="ingresos"
                name="Ingresos"
                fill="hsl(var(--success))"
                radius={[4, 4, 0, 0]}
              />
              <Bar
                dataKey="egresos"
                name="Egresos"
                fill="hsl(var(--destructive))"
                radius={[4, 4, 0, 0]}
              />
            </RechartsBarChart>
          </ResponsiveContainer>
        </div>
      </CardContent>
    </Card>
  );
}

Step 3: Crear apps/web/components/charts/index.ts

export { KpiCard } from './kpi-card';
export { BarChart } from './bar-chart';

Step 4: Commit

git add apps/web/components/charts/
git commit -m "feat(web): add chart components (KpiCard, BarChart)"

Task 9: Crear Hooks y API Client para Dashboard

Files:

  • Create: apps/web/lib/api/dashboard.ts
  • Create: apps/web/lib/hooks/use-dashboard.ts

Step 1: Crear apps/web/lib/api/dashboard.ts

import { apiClient } from './client';
import type { KpiData, IngresosEgresosData, ResumenFiscal, Alerta } from '@horux/shared';

export async function getKpis(año?: number, mes?: number): Promise<KpiData> {
  const params = new URLSearchParams();
  if (año) params.set('año', año.toString());
  if (mes) params.set('mes', mes.toString());

  const response = await apiClient.get<KpiData>(`/dashboard/kpis?${params}`);
  return response.data;
}

export async function getIngresosEgresos(año?: number): Promise<IngresosEgresosData[]> {
  const params = new URLSearchParams();
  if (año) params.set('año', año.toString());

  const response = await apiClient.get<IngresosEgresosData[]>(`/dashboard/ingresos-egresos?${params}`);
  return response.data;
}

export async function getResumenFiscal(año?: number, mes?: number): Promise<ResumenFiscal> {
  const params = new URLSearchParams();
  if (año) params.set('año', año.toString());
  if (mes) params.set('mes', mes.toString());

  const response = await apiClient.get<ResumenFiscal>(`/dashboard/resumen-fiscal?${params}`);
  return response.data;
}

export async function getAlertas(limit = 5): Promise<Alerta[]> {
  const response = await apiClient.get<Alerta[]>(`/dashboard/alertas?limit=${limit}`);
  return response.data;
}

Step 2: Crear apps/web/lib/hooks/use-dashboard.ts

import { useQuery } from '@tanstack/react-query';
import * as dashboardApi from '@/lib/api/dashboard';

export function useKpis(año?: number, mes?: number) {
  return useQuery({
    queryKey: ['kpis', año, mes],
    queryFn: () => dashboardApi.getKpis(año, mes),
  });
}

export function useIngresosEgresos(año?: number) {
  return useQuery({
    queryKey: ['ingresos-egresos', año],
    queryFn: () => dashboardApi.getIngresosEgresos(año),
  });
}

export function useResumenFiscal(año?: number, mes?: number) {
  return useQuery({
    queryKey: ['resumen-fiscal', año, mes],
    queryFn: () => dashboardApi.getResumenFiscal(año, mes),
  });
}

export function useAlertas(limit = 5) {
  return useQuery({
    queryKey: ['alertas', limit],
    queryFn: () => dashboardApi.getAlertas(limit),
  });
}

Step 3: Commit

git add apps/web/lib/api/dashboard.ts apps/web/lib/hooks/
git commit -m "feat(web): add dashboard API client and hooks"

Task 10: Crear Página del Dashboard

Files:

  • Create: apps/web/app/(dashboard)/dashboard/page.tsx
  • Create: apps/web/components/providers/query-provider.tsx
  • Modify: apps/web/app/layout.tsx

Step 1: Crear apps/web/components/providers/query-provider.tsx

'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';

export function QueryProvider({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 60 * 1000,
            refetchOnWindowFocus: false,
          },
        },
      })
  );

  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
}

Step 2: Actualizar apps/web/app/layout.tsx

import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { ThemeProvider } from '@/components/providers/theme-provider';
import { QueryProvider } from '@/components/providers/query-provider';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: 'Horux360 - Análisis Financiero',
  description: 'Plataforma de análisis financiero y gestión fiscal para empresas mexicanas',
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="es" suppressHydrationWarning>
      <body className={inter.className}>
        <QueryProvider>
          <ThemeProvider>{children}</ThemeProvider>
        </QueryProvider>
      </body>
    </html>
  );
}

Step 3: Crear apps/web/app/(dashboard)/dashboard/page.tsx

'use client';

import { Header } from '@/components/layouts/header';
import { KpiCard } from '@/components/charts/kpi-card';
import { BarChart } from '@/components/charts/bar-chart';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useKpis, useIngresosEgresos, useAlertas, useResumenFiscal } from '@/lib/hooks/use-dashboard';
import {
  TrendingUp,
  TrendingDown,
  Wallet,
  Receipt,
  FileText,
  AlertTriangle,
} from 'lucide-react';

export default function DashboardPage() {
  const currentYear = new Date().getFullYear();
  const currentMonth = new Date().getMonth() + 1;

  const { data: kpis, isLoading: kpisLoading } = useKpis(currentYear, currentMonth);
  const { data: chartData, isLoading: chartLoading } = useIngresosEgresos(currentYear);
  const { data: alertas, isLoading: alertasLoading } = useAlertas(5);
  const { data: resumenFiscal } = useResumenFiscal(currentYear, currentMonth);

  const formatCurrency = (value: number) =>
    new Intl.NumberFormat('es-MX', {
      style: 'currency',
      currency: 'MXN',
      minimumFractionDigits: 0,
    }).format(value);

  return (
    <>
      <Header title="Dashboard" />
      <main className="p-6 space-y-6">
        {/* KPIs */}
        <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
          <KpiCard
            title="Ingresos del Mes"
            value={kpis?.ingresos || 0}
            icon={<TrendingUp className="h-4 w-4" />}
            trend="up"
            trendValue="+12.5%"
            subtitle="vs mes anterior"
          />
          <KpiCard
            title="Egresos del Mes"
            value={kpis?.egresos || 0}
            icon={<TrendingDown className="h-4 w-4" />}
            trend="down"
            trendValue="-3.2%"
            subtitle="vs mes anterior"
          />
          <KpiCard
            title="Utilidad"
            value={kpis?.utilidad || 0}
            icon={<Wallet className="h-4 w-4" />}
            trend={kpis?.utilidad && kpis.utilidad > 0 ? 'up' : 'down'}
            trendValue={`${kpis?.margen || 0}% margen`}
          />
          <KpiCard
            title="Balance IVA"
            value={kpis?.ivaBalance || 0}
            icon={<Receipt className="h-4 w-4" />}
            trend={kpis?.ivaBalance && kpis.ivaBalance > 0 ? 'up' : 'down'}
            trendValue={kpis?.ivaBalance && kpis.ivaBalance > 0 ? 'Por pagar' : 'A favor'}
          />
        </div>

        {/* Charts and Alerts */}
        <div className="grid gap-6 lg:grid-cols-3">
          <div className="lg:col-span-2">
            <BarChart
              title="Ingresos vs Egresos"
              data={chartData || []}
            />
          </div>

          <Card>
            <CardHeader>
              <CardTitle className="flex items-center gap-2 text-base font-medium">
                <AlertTriangle className="h-4 w-4" />
                Alertas
              </CardTitle>
            </CardHeader>
            <CardContent className="space-y-3">
              {alertasLoading ? (
                <p className="text-sm text-muted-foreground">Cargando...</p>
              ) : alertas?.length === 0 ? (
                <p className="text-sm text-muted-foreground">No hay alertas pendientes</p>
              ) : (
                alertas?.map((alerta) => (
                  <div
                    key={alerta.id}
                    className={`p-3 rounded-lg border ${
                      alerta.prioridad === 'alta'
                        ? 'border-destructive/50 bg-destructive/10'
                        : 'border-border bg-muted/50'
                    }`}
                  >
                    <p className="text-sm font-medium">{alerta.titulo}</p>
                    <p className="text-xs text-muted-foreground mt-1">
                      {alerta.mensaje}
                    </p>
                  </div>
                ))
              )}
            </CardContent>
          </Card>
        </div>

        {/* Resumen Fiscal */}
        <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
          <Card>
            <CardContent className="p-4">
              <p className="text-sm text-muted-foreground">CFDIs Emitidos</p>
              <p className="text-2xl font-bold">{kpis?.cfdisEmitidos || 0}</p>
            </CardContent>
          </Card>
          <Card>
            <CardContent className="p-4">
              <p className="text-sm text-muted-foreground">CFDIs Recibidos</p>
              <p className="text-2xl font-bold">{kpis?.cfdisRecibidos || 0}</p>
            </CardContent>
          </Card>
          <Card>
            <CardContent className="p-4">
              <p className="text-sm text-muted-foreground">IVA a Favor Acumulado</p>
              <p className="text-2xl font-bold text-success">
                {formatCurrency(resumenFiscal?.ivaAFavor || 0)}
              </p>
            </CardContent>
          </Card>
          <Card>
            <CardContent className="p-4">
              <p className="text-sm text-muted-foreground">Declaraciones Pendientes</p>
              <p className="text-2xl font-bold">
                {resumenFiscal?.declaracionesPendientes || 0}
              </p>
            </CardContent>
          </Card>
        </div>
      </main>
    </>
  );
}

Step 4: Commit

git add apps/web/
git commit -m "feat(web): add dashboard page with KPIs, charts, and alerts"

Task 11: Crear Página de CFDI

Files:

  • Create: apps/web/lib/api/cfdi.ts
  • Create: apps/web/lib/hooks/use-cfdi.ts
  • Create: apps/web/app/(dashboard)/cfdi/page.tsx

Step 1: Crear apps/web/lib/api/cfdi.ts

import { apiClient } from './client';
import type { CfdiListResponse, CfdiFilters, Cfdi } from '@horux/shared';

export async function getCfdis(filters: CfdiFilters): Promise<CfdiListResponse> {
  const params = new URLSearchParams();

  if (filters.tipo) params.set('tipo', filters.tipo);
  if (filters.estado) params.set('estado', filters.estado);
  if (filters.fechaInicio) params.set('fechaInicio', filters.fechaInicio);
  if (filters.fechaFin) params.set('fechaFin', filters.fechaFin);
  if (filters.rfc) params.set('rfc', filters.rfc);
  if (filters.search) params.set('search', filters.search);
  if (filters.page) params.set('page', filters.page.toString());
  if (filters.limit) params.set('limit', filters.limit.toString());

  const response = await apiClient.get<CfdiListResponse>(`/cfdi?${params}`);
  return response.data;
}

export async function getCfdiById(id: string): Promise<Cfdi> {
  const response = await apiClient.get<Cfdi>(`/cfdi/${id}`);
  return response.data;
}

export async function getResumenCfdi(año?: number, mes?: number) {
  const params = new URLSearchParams();
  if (año) params.set('año', año.toString());
  if (mes) params.set('mes', mes.toString());

  const response = await apiClient.get(`/cfdi/resumen?${params}`);
  return response.data;
}

Step 2: Crear apps/web/lib/hooks/use-cfdi.ts

import { useQuery } from '@tanstack/react-query';
import * as cfdiApi from '@/lib/api/cfdi';
import type { CfdiFilters } from '@horux/shared';

export function useCfdis(filters: CfdiFilters) {
  return useQuery({
    queryKey: ['cfdis', filters],
    queryFn: () => cfdiApi.getCfdis(filters),
  });
}

export function useCfdi(id: string) {
  return useQuery({
    queryKey: ['cfdi', id],
    queryFn: () => cfdiApi.getCfdiById(id),
    enabled: !!id,
  });
}

export function useResumenCfdi(año?: number, mes?: number) {
  return useQuery({
    queryKey: ['cfdi-resumen', año, mes],
    queryFn: () => cfdiApi.getResumenCfdi(año, mes),
  });
}

Step 3: Crear apps/web/app/(dashboard)/cfdi/page.tsx

'use client';

import { useState } from 'react';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useCfdis } from '@/lib/hooks/use-cfdi';
import type { CfdiFilters, TipoCfdi } from '@horux/shared';
import { FileText, Search, ChevronLeft, ChevronRight } from 'lucide-react';

export default function CfdiPage() {
  const [filters, setFilters] = useState<CfdiFilters>({
    page: 1,
    limit: 20,
  });
  const [searchTerm, setSearchTerm] = useState('');

  const { data, isLoading } = useCfdis(filters);

  const handleSearch = () => {
    setFilters({ ...filters, search: searchTerm, page: 1 });
  };

  const handleFilterType = (tipo?: TipoCfdi) => {
    setFilters({ ...filters, tipo, page: 1 });
  };

  const formatCurrency = (value: number) =>
    new Intl.NumberFormat('es-MX', {
      style: 'currency',
      currency: 'MXN',
    }).format(value);

  const formatDate = (dateString: string) =>
    new Date(dateString).toLocaleDateString('es-MX', {
      day: '2-digit',
      month: 'short',
      year: 'numeric',
    });

  return (
    <>
      <Header title="Gestión de CFDI" />
      <main className="p-6 space-y-6">
        {/* Filters */}
        <Card>
          <CardContent className="p-4">
            <div className="flex flex-wrap gap-4">
              <div className="flex gap-2 flex-1 min-w-[300px]">
                <Input
                  placeholder="Buscar por UUID, RFC o nombre..."
                  value={searchTerm}
                  onChange={(e) => setSearchTerm(e.target.value)}
                  onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
                />
                <Button onClick={handleSearch}>
                  <Search className="h-4 w-4" />
                </Button>
              </div>
              <div className="flex gap-2">
                <Button
                  variant={filters.tipo === undefined ? 'default' : 'outline'}
                  size="sm"
                  onClick={() => handleFilterType(undefined)}
                >
                  Todos
                </Button>
                <Button
                  variant={filters.tipo === 'ingreso' ? 'default' : 'outline'}
                  size="sm"
                  onClick={() => handleFilterType('ingreso')}
                >
                  Ingresos
                </Button>
                <Button
                  variant={filters.tipo === 'egreso' ? 'default' : 'outline'}
                  size="sm"
                  onClick={() => handleFilterType('egreso')}
                >
                  Egresos
                </Button>
              </div>
            </div>
          </CardContent>
        </Card>

        {/* Table */}
        <Card>
          <CardHeader>
            <CardTitle className="flex items-center gap-2 text-base">
              <FileText className="h-4 w-4" />
              CFDIs ({data?.total || 0})
            </CardTitle>
          </CardHeader>
          <CardContent>
            {isLoading ? (
              <div className="text-center py-8 text-muted-foreground">
                Cargando...
              </div>
            ) : data?.data.length === 0 ? (
              <div className="text-center py-8 text-muted-foreground">
                No se encontraron CFDIs
              </div>
            ) : (
              <div className="overflow-x-auto">
                <table className="w-full">
                  <thead>
                    <tr className="border-b text-left text-sm text-muted-foreground">
                      <th className="pb-3 font-medium">Fecha</th>
                      <th className="pb-3 font-medium">Tipo</th>
                      <th className="pb-3 font-medium">Serie/Folio</th>
                      <th className="pb-3 font-medium">Emisor/Receptor</th>
                      <th className="pb-3 font-medium text-right">Total</th>
                      <th className="pb-3 font-medium">Estado</th>
                    </tr>
                  </thead>
                  <tbody className="text-sm">
                    {data?.data.map((cfdi) => (
                      <tr key={cfdi.id} className="border-b hover:bg-muted/50">
                        <td className="py-3">{formatDate(cfdi.fechaEmision)}</td>
                        <td className="py-3">
                          <span
                            className={`px-2 py-1 rounded-full text-xs font-medium ${
                              cfdi.tipo === 'ingreso'
                                ? 'bg-success/10 text-success'
                                : 'bg-destructive/10 text-destructive'
                            }`}
                          >
                            {cfdi.tipo === 'ingreso' ? 'Ingreso' : 'Egreso'}
                          </span>
                        </td>
                        <td className="py-3">
                          {cfdi.serie || '-'}-{cfdi.folio || '-'}
                        </td>
                        <td className="py-3">
                          <div>
                            <p className="font-medium">
                              {cfdi.tipo === 'ingreso'
                                ? cfdi.nombreReceptor
                                : cfdi.nombreEmisor}
                            </p>
                            <p className="text-xs text-muted-foreground">
                              {cfdi.tipo === 'ingreso'
                                ? cfdi.rfcReceptor
                                : cfdi.rfcEmisor}
                            </p>
                          </div>
                        </td>
                        <td className="py-3 text-right font-medium">
                          {formatCurrency(cfdi.total)}
                        </td>
                        <td className="py-3">
                          <span
                            className={`px-2 py-1 rounded-full text-xs font-medium ${
                              cfdi.estado === 'vigente'
                                ? 'bg-success/10 text-success'
                                : 'bg-muted text-muted-foreground'
                            }`}
                          >
                            {cfdi.estado === 'vigente' ? 'Vigente' : 'Cancelado'}
                          </span>
                        </td>
                      </tr>
                    ))}
                  </tbody>
                </table>
              </div>
            )}

            {/* Pagination */}
            {data && data.totalPages > 1 && (
              <div className="flex items-center justify-between mt-4 pt-4 border-t">
                <p className="text-sm text-muted-foreground">
                  Página {data.page} de {data.totalPages}
                </p>
                <div className="flex gap-2">
                  <Button
                    variant="outline"
                    size="sm"
                    disabled={data.page <= 1}
                    onClick={() =>
                      setFilters({ ...filters, page: (filters.page || 1) - 1 })
                    }
                  >
                    <ChevronLeft className="h-4 w-4" />
                  </Button>
                  <Button
                    variant="outline"
                    size="sm"
                    disabled={data.page >= data.totalPages}
                    onClick={() =>
                      setFilters({ ...filters, page: (filters.page || 1) + 1 })
                    }
                  >
                    <ChevronRight className="h-4 w-4" />
                  </Button>
                </div>
              </div>
            )}
          </CardContent>
        </Card>
      </main>
    </>
  );
}

Step 4: Commit

git add apps/web/
git commit -m "feat(web): add CFDI page with list, filters, and pagination"

Task 12: Crear Página de Impuestos

Files:

  • Create: apps/web/lib/api/impuestos.ts
  • Create: apps/web/lib/hooks/use-impuestos.ts
  • Create: apps/web/app/(dashboard)/impuestos/page.tsx

Step 1: Crear apps/web/lib/api/impuestos.ts

import { apiClient } from './client';
import type { IvaMensual, IsrMensual, ResumenIva, ResumenIsr } from '@horux/shared';

export async function getIvaMensual(año?: number): Promise<IvaMensual[]> {
  const params = año ? `?año=${año}` : '';
  const response = await apiClient.get<IvaMensual[]>(`/impuestos/iva/mensual${params}`);
  return response.data;
}

export async function getResumenIva(año?: number, mes?: number): Promise<ResumenIva> {
  const params = new URLSearchParams();
  if (año) params.set('año', año.toString());
  if (mes) params.set('mes', mes.toString());

  const response = await apiClient.get<ResumenIva>(`/impuestos/iva/resumen?${params}`);
  return response.data;
}

export async function getIsrMensual(año?: number): Promise<IsrMensual[]> {
  const params = año ? `?año=${año}` : '';
  const response = await apiClient.get<IsrMensual[]>(`/impuestos/isr/mensual${params}`);
  return response.data;
}

export async function getResumenIsr(año?: number, mes?: number): Promise<ResumenIsr> {
  const params = new URLSearchParams();
  if (año) params.set('año', año.toString());
  if (mes) params.set('mes', mes.toString());

  const response = await apiClient.get<ResumenIsr>(`/impuestos/isr/resumen?${params}`);
  return response.data;
}

Step 2: Crear apps/web/lib/hooks/use-impuestos.ts

import { useQuery } from '@tanstack/react-query';
import * as impuestosApi from '@/lib/api/impuestos';

export function useIvaMensual(año?: number) {
  return useQuery({
    queryKey: ['iva-mensual', año],
    queryFn: () => impuestosApi.getIvaMensual(año),
  });
}

export function useResumenIva(año?: number, mes?: number) {
  return useQuery({
    queryKey: ['iva-resumen', año, mes],
    queryFn: () => impuestosApi.getResumenIva(año, mes),
  });
}

export function useIsrMensual(año?: number) {
  return useQuery({
    queryKey: ['isr-mensual', año],
    queryFn: () => impuestosApi.getIsrMensual(año),
  });
}

export function useResumenIsr(año?: number, mes?: number) {
  return useQuery({
    queryKey: ['isr-resumen', año, mes],
    queryFn: () => impuestosApi.getResumenIsr(año, mes),
  });
}

Step 3: Crear apps/web/app/(dashboard)/impuestos/page.tsx

'use client';

import { useState } from 'react';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { KpiCard } from '@/components/charts/kpi-card';
import { useIvaMensual, useResumenIva, useResumenIsr } from '@/lib/hooks/use-impuestos';
import { Calculator, TrendingUp, TrendingDown, Receipt } from 'lucide-react';

const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];

export default function ImpuestosPage() {
  const currentYear = new Date().getFullYear();
  const currentMonth = new Date().getMonth() + 1;
  const [año] = useState(currentYear);
  const [activeTab, setActiveTab] = useState<'iva' | 'isr'>('iva');

  const { data: ivaMensual, isLoading: ivaLoading } = useIvaMensual(año);
  const { data: resumenIva } = useResumenIva(año, currentMonth);
  const { data: resumenIsr } = useResumenIsr(año, currentMonth);

  const formatCurrency = (value: number) =>
    new Intl.NumberFormat('es-MX', {
      style: 'currency',
      currency: 'MXN',
      minimumFractionDigits: 0,
    }).format(value);

  return (
    <>
      <Header title="Control de Impuestos" />
      <main className="p-6 space-y-6">
        {/* Tabs */}
        <div className="flex gap-2">
          <Button
            variant={activeTab === 'iva' ? 'default' : 'outline'}
            onClick={() => setActiveTab('iva')}
          >
            <Receipt className="h-4 w-4 mr-2" />
            IVA
          </Button>
          <Button
            variant={activeTab === 'isr' ? 'default' : 'outline'}
            onClick={() => setActiveTab('isr')}
          >
            <Calculator className="h-4 w-4 mr-2" />
            ISR
          </Button>
        </div>

        {activeTab === 'iva' && (
          <>
            {/* IVA KPIs */}
            <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
              <KpiCard
                title="IVA Trasladado"
                value={resumenIva?.trasladado || 0}
                icon={<TrendingUp className="h-4 w-4" />}
                subtitle="Cobrado a clientes"
              />
              <KpiCard
                title="IVA Acreditable"
                value={resumenIva?.acreditable || 0}
                icon={<TrendingDown className="h-4 w-4" />}
                subtitle="Pagado a proveedores"
              />
              <KpiCard
                title="Resultado del Mes"
                value={resumenIva?.resultado || 0}
                icon={<Calculator className="h-4 w-4" />}
                trend={(resumenIva?.resultado || 0) > 0 ? 'up' : 'down'}
                trendValue={(resumenIva?.resultado || 0) > 0 ? 'Por pagar' : 'A favor'}
              />
              <KpiCard
                title="Acumulado Anual"
                value={resumenIva?.acumuladoAnual || 0}
                icon={<Receipt className="h-4 w-4" />}
                trend={(resumenIva?.acumuladoAnual || 0) < 0 ? 'up' : 'neutral'}
                trendValue={(resumenIva?.acumuladoAnual || 0) < 0 ? 'Saldo a favor' : ''}
              />
            </div>

            {/* IVA Mensual Table */}
            <Card>
              <CardHeader>
                <CardTitle className="text-base">Histórico IVA {año}</CardTitle>
              </CardHeader>
              <CardContent>
                {ivaLoading ? (
                  <div className="text-center py-8 text-muted-foreground">
                    Cargando...
                  </div>
                ) : (
                  <div className="overflow-x-auto">
                    <table className="w-full">
                      <thead>
                        <tr className="border-b text-left text-sm text-muted-foreground">
                          <th className="pb-3 font-medium">Mes</th>
                          <th className="pb-3 font-medium text-right">Trasladado</th>
                          <th className="pb-3 font-medium text-right">Acreditable</th>
                          <th className="pb-3 font-medium text-right">Retenido</th>
                          <th className="pb-3 font-medium text-right">Resultado</th>
                          <th className="pb-3 font-medium text-right">Acumulado</th>
                          <th className="pb-3 font-medium">Estado</th>
                        </tr>
                      </thead>
                      <tbody className="text-sm">
                        {ivaMensual?.map((row) => (
                          <tr key={row.mes} className="border-b hover:bg-muted/50">
                            <td className="py-3 font-medium">{meses[row.mes - 1]}</td>
                            <td className="py-3 text-right">
                              {formatCurrency(row.ivaTrasladado)}
                            </td>
                            <td className="py-3 text-right">
                              {formatCurrency(row.ivaAcreditable)}
                            </td>
                            <td className="py-3 text-right">
                              {formatCurrency(row.ivaRetenido)}
                            </td>
                            <td
                              className={`py-3 text-right font-medium ${
                                row.resultado > 0
                                  ? 'text-destructive'
                                  : 'text-success'
                              }`}
                            >
                              {formatCurrency(row.resultado)}
                            </td>
                            <td
                              className={`py-3 text-right font-medium ${
                                row.acumulado > 0
                                  ? 'text-destructive'
                                  : 'text-success'
                              }`}
                            >
                              {formatCurrency(row.acumulado)}
                            </td>
                            <td className="py-3">
                              <span
                                className={`px-2 py-1 rounded-full text-xs font-medium ${
                                  row.estado === 'declarado'
                                    ? 'bg-success/10 text-success'
                                    : 'bg-warning/10 text-warning'
                                }`}
                              >
                                {row.estado === 'declarado' ? 'Declarado' : 'Pendiente'}
                              </span>
                            </td>
                          </tr>
                        ))}
                        {(!ivaMensual || ivaMensual.length === 0) && (
                          <tr>
                            <td colSpan={7} className="py-8 text-center text-muted-foreground">
                              No hay registros de IVA para este año
                            </td>
                          </tr>
                        )}
                      </tbody>
                    </table>
                  </div>
                )}
              </CardContent>
            </Card>
          </>
        )}

        {activeTab === 'isr' && (
          <>
            {/* ISR KPIs */}
            <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
              <KpiCard
                title="Ingresos Acumulados"
                value={resumenIsr?.ingresosAcumulados || 0}
                icon={<TrendingUp className="h-4 w-4" />}
              />
              <KpiCard
                title="Deducciones"
                value={resumenIsr?.deducciones || 0}
                icon={<TrendingDown className="h-4 w-4" />}
              />
              <KpiCard
                title="Base Gravable"
                value={resumenIsr?.baseGravable || 0}
                icon={<Calculator className="h-4 w-4" />}
              />
              <KpiCard
                title="ISR a Pagar"
                value={resumenIsr?.isrAPagar || 0}
                icon={<Receipt className="h-4 w-4" />}
                trend={(resumenIsr?.isrAPagar || 0) > 0 ? 'up' : 'neutral'}
              />
            </div>

            {/* ISR Info Card */}
            <Card>
              <CardHeader>
                <CardTitle className="text-base">Cálculo de ISR Acumulado</CardTitle>
              </CardHeader>
              <CardContent>
                <div className="space-y-4">
                  <div className="flex justify-between py-2 border-b">
                    <span className="text-muted-foreground">Ingresos acumulados</span>
                    <span className="font-medium">
                      {formatCurrency(resumenIsr?.ingresosAcumulados || 0)}
                    </span>
                  </div>
                  <div className="flex justify-between py-2 border-b">
                    <span className="text-muted-foreground">(-) Deducciones autorizadas</span>
                    <span className="font-medium">
                      {formatCurrency(resumenIsr?.deducciones || 0)}
                    </span>
                  </div>
                  <div className="flex justify-between py-2 border-b">
                    <span className="text-muted-foreground">(=) Base gravable</span>
                    <span className="font-medium">
                      {formatCurrency(resumenIsr?.baseGravable || 0)}
                    </span>
                  </div>
                  <div className="flex justify-between py-2 border-b">
                    <span className="text-muted-foreground">ISR causado (estimado)</span>
                    <span className="font-medium">
                      {formatCurrency(resumenIsr?.isrCausado || 0)}
                    </span>
                  </div>
                  <div className="flex justify-between py-2 border-b">
                    <span className="text-muted-foreground">(-) ISR retenido</span>
                    <span className="font-medium">
                      {formatCurrency(resumenIsr?.isrRetenido || 0)}
                    </span>
                  </div>
                  <div className="flex justify-between py-2 bg-muted/50 px-4 rounded-lg">
                    <span className="font-medium">ISR a pagar</span>
                    <span className="font-bold text-lg">
                      {formatCurrency(resumenIsr?.isrAPagar || 0)}
                    </span>
                  </div>
                </div>
              </CardContent>
            </Card>
          </>
        )}
      </main>
    </>
  );
}

Step 4: Commit

git add apps/web/
git commit -m "feat(web): add impuestos page with IVA/ISR control"

Task 13: Crear Página de Configuración

Files:

  • Create: apps/web/app/(dashboard)/configuracion/page.tsx

Step 1: Crear página de configuración

'use client';

import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { useThemeStore } from '@/stores/theme-store';
import { useAuthStore } from '@/stores/auth-store';
import { themes, type ThemeName } from '@/themes';
import { Check, Palette, User, Building } from 'lucide-react';

const themeOptions: { name: ThemeName; label: string; description: string }[] = [
  { name: 'light', label: 'Light', description: 'Tema claro profesional' },
  { name: 'vibrant', label: 'Vibrant', description: 'Colores vivos y modernos' },
  { name: 'corporate', label: 'Corporate', description: 'Diseño empresarial denso' },
  { name: 'dark', label: 'Dark', description: 'Modo oscuro con acentos neón' },
];

export default function ConfiguracionPage() {
  const { theme, setTheme } = useThemeStore();
  const { user } = useAuthStore();

  return (
    <>
      <Header title="Configuración" />
      <main className="p-6 space-y-6">
        {/* User Info */}
        <Card>
          <CardHeader>
            <CardTitle className="flex items-center gap-2 text-base">
              <User className="h-4 w-4" />
              Información del Usuario
            </CardTitle>
          </CardHeader>
          <CardContent className="space-y-4">
            <div className="grid gap-4 md:grid-cols-2">
              <div>
                <p className="text-sm text-muted-foreground">Nombre</p>
                <p className="font-medium">{user?.nombre}</p>
              </div>
              <div>
                <p className="text-sm text-muted-foreground">Email</p>
                <p className="font-medium">{user?.email}</p>
              </div>
              <div>
                <p className="text-sm text-muted-foreground">Rol</p>
                <p className="font-medium capitalize">{user?.role}</p>
              </div>
            </div>
          </CardContent>
        </Card>

        {/* Company Info */}
        <Card>
          <CardHeader>
            <CardTitle className="flex items-center gap-2 text-base">
              <Building className="h-4 w-4" />
              Información de la Empresa
            </CardTitle>
          </CardHeader>
          <CardContent className="space-y-4">
            <div className="grid gap-4 md:grid-cols-2">
              <div>
                <p className="text-sm text-muted-foreground">Empresa</p>
                <p className="font-medium">{user?.tenantNombre}</p>
              </div>
              <div>
                <p className="text-sm text-muted-foreground">Plan</p>
                <p className="font-medium capitalize">{user?.plan}</p>
              </div>
            </div>
          </CardContent>
        </Card>

        {/* Theme Selection */}
        <Card>
          <CardHeader>
            <CardTitle className="flex items-center gap-2 text-base">
              <Palette className="h-4 w-4" />
              Tema Visual
            </CardTitle>
            <CardDescription>
              Elige el tema que mejor se adapte a tu preferencia
            </CardDescription>
          </CardHeader>
          <CardContent>
            <div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
              {themeOptions.map((option) => (
                <button
                  key={option.name}
                  onClick={() => setTheme(option.name)}
                  className={`relative p-4 rounded-lg border-2 text-left transition-all ${
                    theme === option.name
                      ? 'border-primary bg-primary/5'
                      : 'border-border hover:border-primary/50'
                  }`}
                >
                  {theme === option.name && (
                    <div className="absolute top-2 right-2">
                      <Check className="h-4 w-4 text-primary" />
                    </div>
                  )}
                  <div
                    className="h-20 rounded-md mb-3"
                    style={{
                      background: themes[option.name].colors.primary,
                    }}
                  />
                  <p className="font-medium">{option.label}</p>
                  <p className="text-xs text-muted-foreground">
                    {option.description}
                  </p>
                </button>
              ))}
            </div>
          </CardContent>
        </Card>
      </main>
    </>
  );
}

Step 2: Commit

git add apps/web/app/\(dashboard\)/configuracion/
git commit -m "feat(web): add configuracion page with theme selector"

Task 14: Actualizar Home Page y Redirect

Files:

  • Modify: apps/web/app/page.tsx

Step 1: Actualizar apps/web/app/page.tsx

import { redirect } from 'next/navigation';

export default function Home() {
  redirect('/dashboard');
}

Step 2: Commit

git add apps/web/app/page.tsx
git commit -m "feat(web): redirect home to dashboard"

Task 15: Agregar Lucide Icons y Finalizar

Files:

  • Verify all imports and add missing CSS variables

Step 1: Verificar que lucide-react está en package.json

Ya está incluido en apps/web/package.json

Step 2: Agregar variables CSS faltantes a globals.css

Agregar en apps/web/app/globals.css después de las variables existentes:

/* Add to :root */
--warning: 38 92% 50%;
--warning-foreground: 0 0% 100%;

Step 3: Commit final

git add -A
git commit -m "chore: Phase 2 complete - Dashboard, CFDI, Impuestos modules"
git push origin main

Summary

Phase 2 establishes:

  • Dashboard with KPIs (ingresos, egresos, utilidad, IVA balance)
  • Interactive bar chart (Ingresos vs Egresos)
  • Alerts widget
  • CFDI management (list, filters, pagination)
  • IVA control (monthly history, calculations)
  • ISR control (accumulated calculations)
  • Theme selector in configuration
  • Protected dashboard layout with sidebar

API Endpoints Created:

  • GET /api/dashboard/kpis
  • GET /api/dashboard/ingresos-egresos
  • GET /api/dashboard/resumen-fiscal
  • GET /api/dashboard/alertas
  • GET /api/cfdi
  • GET /api/cfdi/:id
  • GET /api/cfdi/resumen
  • GET /api/impuestos/iva/mensual
  • GET /api/impuestos/iva/resumen
  • GET /api/impuestos/isr/mensual
  • GET /api/impuestos/isr/resumen

Next Phase (3): Reportes, Exportación Excel/PDF, Sistema de alertas, Calendario fiscal, Gestión de usuarios.