diff --git a/docs/plans/2026-01-22-fase2-implementation.md b/docs/plans/2026-01-22-fase2-implementation.md new file mode 100644 index 0000000..1905cf5 --- /dev/null +++ b/docs/plans/2026-01-22-fase2-implementation.md @@ -0,0 +1,2732 @@ +# 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) => ( + + + + + + + + + ))} + +
FechaTipoSerie/FolioEmisor/ReceptorTotalEstado
{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) && ( + + + + )} + +
MesTrasladadoAcreditableRetenidoResultadoAcumuladoEstado
{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.