# 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** ```json { "dependencies": { "recharts": "^2.12.0", "@tanstack/react-table": "^8.20.0", "date-fns": "^3.6.0" } } ``` **Step 2: Commit** ```bash 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** ```typescript 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** ```typescript 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** ```typescript 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** ```typescript // 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** ```bash 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** ```typescript 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** ```bash 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** ```typescript 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 { 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 { 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 { 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 { const alertas = await prisma.$queryRawUnsafe(` 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** ```typescript 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** ```typescript 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** ```typescript 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** ```bash 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** ```typescript import { prisma } from '../config/database.js'; import type { Cfdi, CfdiFilters, CfdiListResponse } from '@horux/shared'; export async function getCfdis(schema: string, filters: CfdiFilters): Promise { 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(` 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 { const result = await prisma.$queryRawUnsafe(` 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** ```typescript 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** ```typescript 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** ```typescript 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** ```bash 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** ```typescript 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 { const data = await prisma.$queryRawUnsafe(` 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 { // Get from iva_mensual if exists const [existing] = await prisma.$queryRawUnsafe(` 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 { // Check if isr_mensual table exists try { const data = await prisma.$queryRawUnsafe(` 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 { // 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** ```typescript 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** ```typescript 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** ```typescript 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** ```bash 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** ```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 ( ); } ``` **Step 2: Crear apps/web/components/layouts/header.tsx** ```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 = { light: , vibrant: , corporate: , dark: , }; 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 (

{title}

); } ``` **Step 3: Crear apps/web/components/layouts/dashboard-shell.tsx** ```tsx import { Sidebar } from './sidebar'; import { Header } from './header'; interface DashboardShellProps { children: React.ReactNode; title: string; } export function DashboardShell({ children, title }: DashboardShellProps) { return (
{children}
); } ``` **Step 4: Crear apps/web/app/(dashboard)/layout.tsx** ```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 (
{children}
); } ``` **Step 5: Commit** ```bash 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** ```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 (

{title}

{icon &&
{icon}
}

{formatValue(value)}

{(subtitle || trend) && (
{trend && ( {trend === 'up' && } {trend === 'down' && } {trend === 'neutral' && } {trendValue} )} {subtitle && ( {subtitle} )}
)}
); } ``` **Step 2: Crear apps/web/components/charts/bar-chart.tsx** ```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 ( {title}
new Intl.NumberFormat('es-MX', { style: 'currency', currency: 'MXN', }).format(value) } contentStyle={{ backgroundColor: 'hsl(var(--card))', border: '1px solid hsl(var(--border))', borderRadius: '8px', }} />
); } ``` **Step 3: Crear apps/web/components/charts/index.ts** ```typescript export { KpiCard } from './kpi-card'; export { BarChart } from './bar-chart'; ``` **Step 4: Commit** ```bash 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** ```typescript import { apiClient } from './client'; import type { KpiData, IngresosEgresosData, ResumenFiscal, Alerta } from '@horux/shared'; export async function getKpis(año?: number, mes?: number): Promise { 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(`/dashboard/kpis?${params}`); return response.data; } export async function getIngresosEgresos(año?: number): Promise { const params = new URLSearchParams(); if (año) params.set('año', año.toString()); const response = await apiClient.get(`/dashboard/ingresos-egresos?${params}`); return response.data; } export async function getResumenFiscal(año?: number, mes?: number): Promise { 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(`/dashboard/resumen-fiscal?${params}`); return response.data; } export async function getAlertas(limit = 5): Promise { const response = await apiClient.get(`/dashboard/alertas?limit=${limit}`); return response.data; } ``` **Step 2: Crear apps/web/lib/hooks/use-dashboard.ts** ```typescript 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** ```bash 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** ```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 ( {children} ); } ``` **Step 2: Actualizar apps/web/app/layout.tsx** ```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 ( {children} ); } ``` **Step 3: Crear apps/web/app/(dashboard)/dashboard/page.tsx** ```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 ( <>
{/* KPIs */}
} trend="up" trendValue="+12.5%" subtitle="vs mes anterior" /> } trend="down" trendValue="-3.2%" subtitle="vs mes anterior" /> } trend={kpis?.utilidad && kpis.utilidad > 0 ? 'up' : 'down'} trendValue={`${kpis?.margen || 0}% margen`} /> } trend={kpis?.ivaBalance && kpis.ivaBalance > 0 ? 'up' : 'down'} trendValue={kpis?.ivaBalance && kpis.ivaBalance > 0 ? 'Por pagar' : 'A favor'} />
{/* Charts and Alerts */}
Alertas {alertasLoading ? (

Cargando...

) : alertas?.length === 0 ? (

No hay alertas pendientes

) : ( alertas?.map((alerta) => (

{alerta.titulo}

{alerta.mensaje}

)) )}
{/* Resumen Fiscal */}

CFDIs Emitidos

{kpis?.cfdisEmitidos || 0}

CFDIs Recibidos

{kpis?.cfdisRecibidos || 0}

IVA a Favor Acumulado

{formatCurrency(resumenFiscal?.ivaAFavor || 0)}

Declaraciones Pendientes

{resumenFiscal?.declaracionesPendientes || 0}

); } ``` **Step 4: Commit** ```bash 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** ```typescript import { apiClient } from './client'; import type { CfdiListResponse, CfdiFilters, Cfdi } from '@horux/shared'; export async function getCfdis(filters: CfdiFilters): Promise { 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(`/cfdi?${params}`); return response.data; } export async function getCfdiById(id: string): Promise { const response = await apiClient.get(`/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** ```typescript 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** ```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({ 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 ( <>
{/* Filters */}
setSearchTerm(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleSearch()} />
{/* Table */} CFDIs ({data?.total || 0}) {isLoading ? (
Cargando...
) : data?.data.length === 0 ? (
No se encontraron CFDIs
) : (
{data?.data.map((cfdi) => ( ))}
Fecha Tipo Serie/Folio Emisor/Receptor Total Estado
{formatDate(cfdi.fechaEmision)} {cfdi.tipo === 'ingreso' ? 'Ingreso' : 'Egreso'} {cfdi.serie || '-'}-{cfdi.folio || '-'}

{cfdi.tipo === 'ingreso' ? cfdi.nombreReceptor : cfdi.nombreEmisor}

{cfdi.tipo === 'ingreso' ? cfdi.rfcReceptor : cfdi.rfcEmisor}

{formatCurrency(cfdi.total)} {cfdi.estado === 'vigente' ? 'Vigente' : 'Cancelado'}
)} {/* Pagination */} {data && data.totalPages > 1 && (

Página {data.page} de {data.totalPages}

)}
); } ``` **Step 4: Commit** ```bash 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** ```typescript import { apiClient } from './client'; import type { IvaMensual, IsrMensual, ResumenIva, ResumenIsr } from '@horux/shared'; export async function getIvaMensual(año?: number): Promise { const params = año ? `?año=${año}` : ''; const response = await apiClient.get(`/impuestos/iva/mensual${params}`); return response.data; } export async function getResumenIva(año?: number, mes?: number): Promise { 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(`/impuestos/iva/resumen?${params}`); return response.data; } export async function getIsrMensual(año?: number): Promise { const params = año ? `?año=${año}` : ''; const response = await apiClient.get(`/impuestos/isr/mensual${params}`); return response.data; } export async function getResumenIsr(año?: number, mes?: number): Promise { 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(`/impuestos/isr/resumen?${params}`); return response.data; } ``` **Step 2: Crear apps/web/lib/hooks/use-impuestos.ts** ```typescript 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** ```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 ( <>
{/* Tabs */}
{activeTab === 'iva' && ( <> {/* IVA KPIs */}
} subtitle="Cobrado a clientes" /> } subtitle="Pagado a proveedores" /> } trend={(resumenIva?.resultado || 0) > 0 ? 'up' : 'down'} trendValue={(resumenIva?.resultado || 0) > 0 ? 'Por pagar' : 'A favor'} /> } trend={(resumenIva?.acumuladoAnual || 0) < 0 ? 'up' : 'neutral'} trendValue={(resumenIva?.acumuladoAnual || 0) < 0 ? 'Saldo a favor' : ''} />
{/* IVA Mensual Table */} Histórico IVA {año} {ivaLoading ? (
Cargando...
) : (
{ivaMensual?.map((row) => ( ))} {(!ivaMensual || ivaMensual.length === 0) && ( )}
Mes Trasladado Acreditable Retenido Resultado Acumulado Estado
{meses[row.mes - 1]} {formatCurrency(row.ivaTrasladado)} {formatCurrency(row.ivaAcreditable)} {formatCurrency(row.ivaRetenido)} 0 ? 'text-destructive' : 'text-success' }`} > {formatCurrency(row.resultado)} 0 ? 'text-destructive' : 'text-success' }`} > {formatCurrency(row.acumulado)} {row.estado === 'declarado' ? 'Declarado' : 'Pendiente'}
No hay registros de IVA para este año
)}
)} {activeTab === 'isr' && ( <> {/* ISR KPIs */}
} /> } /> } /> } trend={(resumenIsr?.isrAPagar || 0) > 0 ? 'up' : 'neutral'} />
{/* ISR Info Card */} Cálculo de ISR Acumulado
Ingresos acumulados {formatCurrency(resumenIsr?.ingresosAcumulados || 0)}
(-) Deducciones autorizadas {formatCurrency(resumenIsr?.deducciones || 0)}
(=) Base gravable {formatCurrency(resumenIsr?.baseGravable || 0)}
ISR causado (estimado) {formatCurrency(resumenIsr?.isrCausado || 0)}
(-) ISR retenido {formatCurrency(resumenIsr?.isrRetenido || 0)}
ISR a pagar {formatCurrency(resumenIsr?.isrAPagar || 0)}
)}
); } ``` **Step 4: Commit** ```bash 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** ```tsx '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 ( <>
{/* User Info */} Información del Usuario

Nombre

{user?.nombre}

Email

{user?.email}

Rol

{user?.role}

{/* Company Info */} Información de la Empresa

Empresa

{user?.tenantNombre}

Plan

{user?.plan}

{/* Theme Selection */} Tema Visual Elige el tema que mejor se adapte a tu preferencia
{themeOptions.map((option) => ( ))}
); } ``` **Step 2: Commit** ```bash 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** ```tsx import { redirect } from 'next/navigation'; export default function Home() { redirect('/dashboard'); } ``` **Step 2: Commit** ```bash 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: ```css /* Add to :root */ --warning: 38 92% 50%; --warning-foreground: 0 0% 100%; ``` **Step 3: Commit final** ```bash 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.