- Dashboard with KPIs and charts - CFDI management (list, filters, pagination) - IVA/ISR control modules - Protected dashboard layout - 15 tasks with complete code Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
83 KiB
Fase 2: Módulos Core - Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Implement Dashboard with KPIs, CFDI management, IVA/ISR control, and charts.
Architecture: Backend API endpoints for dashboard data, CFDI CRUD, and tax calculations. Frontend with protected dashboard layout, reusable chart components using Recharts, and data tables with TanStack Table.
Tech Stack: Express + Prisma (backend), Next.js 14 + Recharts + TanStack Table + TanStack Query (frontend)
Task 1: Agregar Dependencias de Fase 2
Files:
- Modify:
apps/web/package.json
Step 1: Agregar dependencias al frontend
{
"dependencies": {
"recharts": "^2.12.0",
"@tanstack/react-table": "^8.20.0",
"date-fns": "^3.6.0"
}
}
Step 2: Commit
git add apps/web/package.json
git commit -m "chore: add Phase 2 dependencies (recharts, tanstack-table, date-fns)"
Task 2: Crear Tipos Compartidos para Dashboard y CFDI
Files:
- Create:
packages/shared/src/types/cfdi.ts - Create:
packages/shared/src/types/dashboard.ts - Create:
packages/shared/src/types/impuestos.ts - Modify:
packages/shared/src/index.ts
Step 1: Crear packages/shared/src/types/cfdi.ts
export type TipoCfdi = 'ingreso' | 'egreso' | 'traslado' | 'pago' | 'nomina';
export type EstadoCfdi = 'vigente' | 'cancelado';
export interface Cfdi {
id: string;
uuidFiscal: string;
tipo: TipoCfdi;
serie: string | null;
folio: string | null;
fechaEmision: string;
fechaTimbrado: string;
rfcEmisor: string;
nombreEmisor: string;
rfcReceptor: string;
nombreReceptor: string;
subtotal: number;
descuento: number;
iva: number;
isrRetenido: number;
ivaRetenido: number;
total: number;
moneda: string;
tipoCambio: number;
metodoPago: string | null;
formaPago: string | null;
usoCfdi: string | null;
estado: EstadoCfdi;
xmlUrl: string | null;
pdfUrl: string | null;
createdAt: string;
}
export interface CfdiFilters {
tipo?: TipoCfdi;
estado?: EstadoCfdi;
fechaInicio?: string;
fechaFin?: string;
rfc?: string;
search?: string;
page?: number;
limit?: number;
}
export interface CfdiListResponse {
data: Cfdi[];
total: number;
page: number;
limit: number;
totalPages: number;
}
Step 2: Crear packages/shared/src/types/dashboard.ts
export interface KpiData {
ingresos: number;
egresos: number;
utilidad: number;
margen: number;
ivaBalance: number;
cfdisEmitidos: number;
cfdisRecibidos: number;
}
export interface IngresosEgresosData {
mes: string;
ingresos: number;
egresos: number;
}
export interface ResumenFiscal {
ivaPorPagar: number;
ivaAFavor: number;
isrPorPagar: number;
declaracionesPendientes: number;
proximaObligacion: {
titulo: string;
fecha: string;
} | null;
}
export interface Alerta {
id: number;
tipo: 'vencimiento' | 'discrepancia' | 'iva_favor' | 'declaracion';
titulo: string;
mensaje: string;
prioridad: 'alta' | 'media' | 'baja';
fechaVencimiento: string | null;
leida: boolean;
resuelta: boolean;
createdAt: string;
}
export type PeriodoFiltro = 'semana' | 'mes' | 'trimestre' | 'año' | 'custom';
export interface DashboardFilters {
periodo: PeriodoFiltro;
fechaInicio?: string;
fechaFin?: string;
}
Step 3: Crear packages/shared/src/types/impuestos.ts
export type EstadoDeclaracion = 'pendiente' | 'declarado' | 'acreditado';
export interface IvaMensual {
id: number;
año: number;
mes: number;
ivaTrasladado: number;
ivaAcreditable: number;
ivaRetenido: number;
resultado: number;
acumulado: number;
estado: EstadoDeclaracion;
fechaDeclaracion: string | null;
}
export interface IsrMensual {
id: number;
año: number;
mes: number;
ingresosAcumulados: number;
deducciones: number;
baseGravable: number;
isrCausado: number;
isrRetenido: number;
isrAPagar: number;
estado: EstadoDeclaracion;
fechaDeclaracion: string | null;
}
export interface ResumenIva {
trasladado: number;
acreditable: number;
retenido: number;
resultado: number;
acumuladoAnual: number;
}
export interface ResumenIsr {
ingresosAcumulados: number;
deducciones: number;
baseGravable: number;
isrCausado: number;
isrRetenido: number;
isrAPagar: number;
}
Step 4: Actualizar packages/shared/src/index.ts
// Types
export * from './types/auth';
export * from './types/tenant';
export * from './types/user';
export * from './types/cfdi';
export * from './types/dashboard';
export * from './types/impuestos';
// Constants
export * from './constants/plans';
export * from './constants/roles';
Step 5: Commit
git add packages/shared/src/
git commit -m "feat(shared): add types for dashboard, cfdi, and impuestos"
Task 3: Crear Middleware de Tenant
Files:
- Create:
apps/api/src/middlewares/tenant.middleware.ts
Step 1: Crear middleware
import type { Request, Response, NextFunction } from 'express';
import { prisma } from '../config/database.js';
import { AppError } from './error.middleware.js';
declare global {
namespace Express {
interface Request {
tenantSchema?: string;
}
}
}
export async function tenantMiddleware(req: Request, res: Response, next: NextFunction) {
if (!req.user) {
return next(new AppError(401, 'No autenticado'));
}
try {
const tenant = await prisma.tenant.findUnique({
where: { id: req.user.tenantId },
select: { schemaName: true, active: true },
});
if (!tenant || !tenant.active) {
return next(new AppError(403, 'Tenant no encontrado o inactivo'));
}
req.tenantSchema = tenant.schemaName;
// Set search_path for this request
await prisma.$executeRawUnsafe(`SET search_path TO "${tenant.schemaName}", public`);
next();
} catch (error) {
next(new AppError(500, 'Error al configurar tenant'));
}
}
Step 2: Commit
git add apps/api/src/middlewares/tenant.middleware.ts
git commit -m "feat(api): add tenant middleware for multi-tenant schema isolation"
Task 4: Crear API de Dashboard
Files:
- Create:
apps/api/src/services/dashboard.service.ts - Create:
apps/api/src/controllers/dashboard.controller.ts - Create:
apps/api/src/routes/dashboard.routes.ts - Modify:
apps/api/src/app.ts
Step 1: Crear apps/api/src/services/dashboard.service.ts
import { prisma } from '../config/database.js';
import type { KpiData, IngresosEgresosData, ResumenFiscal, Alerta } from '@horux/shared';
export async function getKpis(schema: string, año: number, mes: number): Promise<KpiData> {
const [ingresos] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
SELECT COALESCE(SUM(total), 0) as total
FROM "${schema}".cfdis
WHERE tipo = 'ingreso'
AND estado = 'vigente'
AND EXTRACT(YEAR FROM fecha_emision) = $1
AND EXTRACT(MONTH FROM fecha_emision) = $2
`, año, mes);
const [egresos] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
SELECT COALESCE(SUM(total), 0) as total
FROM "${schema}".cfdis
WHERE tipo = 'egreso'
AND estado = 'vigente'
AND EXTRACT(YEAR FROM fecha_emision) = $1
AND EXTRACT(MONTH FROM fecha_emision) = $2
`, año, mes);
const [ivaData] = await prisma.$queryRawUnsafe<[{ trasladado: number; acreditable: number }]>(`
SELECT
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) as trasladado,
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as acreditable
FROM "${schema}".cfdis
WHERE estado = 'vigente'
AND EXTRACT(YEAR FROM fecha_emision) = $1
AND EXTRACT(MONTH FROM fecha_emision) = $2
`, año, mes);
const [counts] = await prisma.$queryRawUnsafe<[{ emitidos: number; recibidos: number }]>(`
SELECT
COUNT(CASE WHEN tipo = 'ingreso' THEN 1 END) as emitidos,
COUNT(CASE WHEN tipo = 'egreso' THEN 1 END) as recibidos
FROM "${schema}".cfdis
WHERE estado = 'vigente'
AND EXTRACT(YEAR FROM fecha_emision) = $1
AND EXTRACT(MONTH FROM fecha_emision) = $2
`, año, mes);
const ingresosVal = Number(ingresos?.total || 0);
const egresosVal = Number(egresos?.total || 0);
const utilidad = ingresosVal - egresosVal;
const margen = ingresosVal > 0 ? (utilidad / ingresosVal) * 100 : 0;
const ivaBalance = Number(ivaData?.trasladado || 0) - Number(ivaData?.acreditable || 0);
return {
ingresos: ingresosVal,
egresos: egresosVal,
utilidad,
margen: Math.round(margen * 100) / 100,
ivaBalance,
cfdisEmitidos: Number(counts?.emitidos || 0),
cfdisRecibidos: Number(counts?.recibidos || 0),
};
}
export async function getIngresosEgresos(schema: string, año: number): Promise<IngresosEgresosData[]> {
const data = await prisma.$queryRawUnsafe<{ mes: number; ingresos: number; egresos: number }[]>(`
SELECT
EXTRACT(MONTH FROM fecha_emision)::int as mes,
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN total ELSE 0 END), 0) as ingresos,
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as egresos
FROM "${schema}".cfdis
WHERE estado = 'vigente'
AND EXTRACT(YEAR FROM fecha_emision) = $1
GROUP BY EXTRACT(MONTH FROM fecha_emision)
ORDER BY mes
`, año);
const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
return meses.map((mes, index) => {
const found = data.find(d => d.mes === index + 1);
return {
mes,
ingresos: Number(found?.ingresos || 0),
egresos: Number(found?.egresos || 0),
};
});
}
export async function getResumenFiscal(schema: string, año: number, mes: number): Promise<ResumenFiscal> {
const [ivaResult] = await prisma.$queryRawUnsafe<[{ resultado: number; acumulado: number }]>(`
SELECT resultado, acumulado FROM "${schema}".iva_mensual
WHERE año = $1 AND mes = $2
`, año, mes) || [{ resultado: 0, acumulado: 0 }];
const [pendientes] = await prisma.$queryRawUnsafe<[{ count: number }]>(`
SELECT COUNT(*) as count FROM "${schema}".iva_mensual
WHERE año = $1 AND estado = 'pendiente'
`, año);
const resultado = Number(ivaResult?.resultado || 0);
const acumulado = Number(ivaResult?.acumulado || 0);
return {
ivaPorPagar: resultado > 0 ? resultado : 0,
ivaAFavor: acumulado < 0 ? Math.abs(acumulado) : 0,
isrPorPagar: 0,
declaracionesPendientes: Number(pendientes?.count || 0),
proximaObligacion: {
titulo: 'Declaración mensual IVA/ISR',
fecha: new Date(año, mes, 17).toISOString(),
},
};
}
export async function getAlertas(schema: string, limit = 5): Promise<Alerta[]> {
const alertas = await prisma.$queryRawUnsafe<Alerta[]>(`
SELECT id, tipo, titulo, mensaje, prioridad,
fecha_vencimiento as "fechaVencimiento",
leida, resuelta,
created_at as "createdAt"
FROM "${schema}".alertas
WHERE resuelta = false
ORDER BY
CASE prioridad WHEN 'alta' THEN 1 WHEN 'media' THEN 2 ELSE 3 END,
created_at DESC
LIMIT $1
`, limit);
return alertas;
}
Step 2: Crear apps/api/src/controllers/dashboard.controller.ts
import type { Request, Response, NextFunction } from 'express';
import * as dashboardService from '../services/dashboard.service.js';
import { AppError } from '../middlewares/error.middleware.js';
export async function getKpis(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
}
const año = parseInt(req.query.año as string) || new Date().getFullYear();
const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1;
const kpis = await dashboardService.getKpis(req.tenantSchema, año, mes);
res.json(kpis);
} catch (error) {
next(error);
}
}
export async function getIngresosEgresos(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
}
const año = parseInt(req.query.año as string) || new Date().getFullYear();
const data = await dashboardService.getIngresosEgresos(req.tenantSchema, año);
res.json(data);
} catch (error) {
next(error);
}
}
export async function getResumenFiscal(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
}
const año = parseInt(req.query.año as string) || new Date().getFullYear();
const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1;
const resumen = await dashboardService.getResumenFiscal(req.tenantSchema, año, mes);
res.json(resumen);
} catch (error) {
next(error);
}
}
export async function getAlertas(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
}
const limit = parseInt(req.query.limit as string) || 5;
const alertas = await dashboardService.getAlertas(req.tenantSchema, limit);
res.json(alertas);
} catch (error) {
next(error);
}
}
Step 3: Crear apps/api/src/routes/dashboard.routes.ts
import { Router } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import * as dashboardController from '../controllers/dashboard.controller.js';
const router = Router();
router.use(authenticate);
router.use(tenantMiddleware);
router.get('/kpis', dashboardController.getKpis);
router.get('/ingresos-egresos', dashboardController.getIngresosEgresos);
router.get('/resumen-fiscal', dashboardController.getResumenFiscal);
router.get('/alertas', dashboardController.getAlertas);
export { router as dashboardRoutes };
Step 4: Actualizar apps/api/src/app.ts
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import { env } from './config/env.js';
import { errorMiddleware } from './middlewares/error.middleware.js';
import { authRoutes } from './routes/auth.routes.js';
import { dashboardRoutes } from './routes/dashboard.routes.js';
const app = express();
// Security
app.use(helmet());
app.use(cors({
origin: env.CORS_ORIGIN,
credentials: true,
}));
// Body parsing
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// API Routes
app.use('/api/auth', authRoutes);
app.use('/api/dashboard', dashboardRoutes);
// Error handling
app.use(errorMiddleware);
export { app };
Step 5: Commit
git add apps/api/src/
git commit -m "feat(api): add dashboard API endpoints (kpis, ingresos-egresos, resumen-fiscal, alertas)"
Task 5: Crear API de CFDI
Files:
- Create:
apps/api/src/services/cfdi.service.ts - Create:
apps/api/src/controllers/cfdi.controller.ts - Create:
apps/api/src/routes/cfdi.routes.ts - Modify:
apps/api/src/app.ts
Step 1: Crear apps/api/src/services/cfdi.service.ts
import { prisma } from '../config/database.js';
import type { Cfdi, CfdiFilters, CfdiListResponse } from '@horux/shared';
export async function getCfdis(schema: string, filters: CfdiFilters): Promise<CfdiListResponse> {
const page = filters.page || 1;
const limit = filters.limit || 20;
const offset = (page - 1) * limit;
let whereClause = 'WHERE 1=1';
const params: any[] = [];
let paramIndex = 1;
if (filters.tipo) {
whereClause += ` AND tipo = $${paramIndex++}`;
params.push(filters.tipo);
}
if (filters.estado) {
whereClause += ` AND estado = $${paramIndex++}`;
params.push(filters.estado);
}
if (filters.fechaInicio) {
whereClause += ` AND fecha_emision >= $${paramIndex++}`;
params.push(filters.fechaInicio);
}
if (filters.fechaFin) {
whereClause += ` AND fecha_emision <= $${paramIndex++}`;
params.push(filters.fechaFin);
}
if (filters.rfc) {
whereClause += ` AND (rfc_emisor ILIKE $${paramIndex} OR rfc_receptor ILIKE $${paramIndex++})`;
params.push(`%${filters.rfc}%`);
}
if (filters.search) {
whereClause += ` AND (uuid_fiscal ILIKE $${paramIndex} OR nombre_emisor ILIKE $${paramIndex} OR nombre_receptor ILIKE $${paramIndex++})`;
params.push(`%${filters.search}%`);
}
const countResult = await prisma.$queryRawUnsafe<[{ count: number }]>(`
SELECT COUNT(*) as count FROM "${schema}".cfdis ${whereClause}
`, ...params);
const total = Number(countResult[0]?.count || 0);
params.push(limit, offset);
const data = await prisma.$queryRawUnsafe<Cfdi[]>(`
SELECT
id, uuid_fiscal as "uuidFiscal", tipo, serie, folio,
fecha_emision as "fechaEmision", fecha_timbrado as "fechaTimbrado",
rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor",
rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor",
subtotal, descuento, iva, isr_retenido as "isrRetenido",
iva_retenido as "ivaRetenido", total, moneda,
tipo_cambio as "tipoCambio", metodo_pago as "metodoPago",
forma_pago as "formaPago", uso_cfdi as "usoCfdi",
estado, xml_url as "xmlUrl", pdf_url as "pdfUrl",
created_at as "createdAt"
FROM "${schema}".cfdis
${whereClause}
ORDER BY fecha_emision DESC
LIMIT $${paramIndex++} OFFSET $${paramIndex}
`, ...params);
return {
data,
total,
page,
limit,
totalPages: Math.ceil(total / limit),
};
}
export async function getCfdiById(schema: string, id: string): Promise<Cfdi | null> {
const result = await prisma.$queryRawUnsafe<Cfdi[]>(`
SELECT
id, uuid_fiscal as "uuidFiscal", tipo, serie, folio,
fecha_emision as "fechaEmision", fecha_timbrado as "fechaTimbrado",
rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor",
rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor",
subtotal, descuento, iva, isr_retenido as "isrRetenido",
iva_retenido as "ivaRetenido", total, moneda,
tipo_cambio as "tipoCambio", metodo_pago as "metodoPago",
forma_pago as "formaPago", uso_cfdi as "usoCfdi",
estado, xml_url as "xmlUrl", pdf_url as "pdfUrl",
created_at as "createdAt"
FROM "${schema}".cfdis
WHERE id = $1
`, id);
return result[0] || null;
}
export async function getResumenCfdis(schema: string, año: number, mes: number) {
const result = await prisma.$queryRawUnsafe<[{
total_ingresos: number;
total_egresos: number;
count_ingresos: number;
count_egresos: number;
iva_trasladado: number;
iva_acreditable: number;
}]>(`
SELECT
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN total ELSE 0 END), 0) as total_ingresos,
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN total ELSE 0 END), 0) as total_egresos,
COUNT(CASE WHEN tipo = 'ingreso' THEN 1 END) as count_ingresos,
COUNT(CASE WHEN tipo = 'egreso' THEN 1 END) as count_egresos,
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) as iva_trasladado,
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as iva_acreditable
FROM "${schema}".cfdis
WHERE estado = 'vigente'
AND EXTRACT(YEAR FROM fecha_emision) = $1
AND EXTRACT(MONTH FROM fecha_emision) = $2
`, año, mes);
const r = result[0];
return {
totalIngresos: Number(r?.total_ingresos || 0),
totalEgresos: Number(r?.total_egresos || 0),
countIngresos: Number(r?.count_ingresos || 0),
countEgresos: Number(r?.count_egresos || 0),
ivaTrasladado: Number(r?.iva_trasladado || 0),
ivaAcreditable: Number(r?.iva_acreditable || 0),
};
}
Step 2: Crear apps/api/src/controllers/cfdi.controller.ts
import type { Request, Response, NextFunction } from 'express';
import * as cfdiService from '../services/cfdi.service.js';
import { AppError } from '../middlewares/error.middleware.js';
import type { CfdiFilters } from '@horux/shared';
export async function getCfdis(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
}
const filters: CfdiFilters = {
tipo: req.query.tipo as any,
estado: req.query.estado as any,
fechaInicio: req.query.fechaInicio as string,
fechaFin: req.query.fechaFin as string,
rfc: req.query.rfc as string,
search: req.query.search as string,
page: parseInt(req.query.page as string) || 1,
limit: parseInt(req.query.limit as string) || 20,
};
const result = await cfdiService.getCfdis(req.tenantSchema, filters);
res.json(result);
} catch (error) {
next(error);
}
}
export async function getCfdiById(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
}
const cfdi = await cfdiService.getCfdiById(req.tenantSchema, req.params.id);
if (!cfdi) {
return next(new AppError(404, 'CFDI no encontrado'));
}
res.json(cfdi);
} catch (error) {
next(error);
}
}
export async function getResumen(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
}
const año = parseInt(req.query.año as string) || new Date().getFullYear();
const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1;
const resumen = await cfdiService.getResumenCfdis(req.tenantSchema, año, mes);
res.json(resumen);
} catch (error) {
next(error);
}
}
Step 3: Crear apps/api/src/routes/cfdi.routes.ts
import { Router } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import * as cfdiController from '../controllers/cfdi.controller.js';
const router = Router();
router.use(authenticate);
router.use(tenantMiddleware);
router.get('/', cfdiController.getCfdis);
router.get('/resumen', cfdiController.getResumen);
router.get('/:id', cfdiController.getCfdiById);
export { router as cfdiRoutes };
Step 4: Actualizar apps/api/src/app.ts para agregar rutas CFDI
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import { env } from './config/env.js';
import { errorMiddleware } from './middlewares/error.middleware.js';
import { authRoutes } from './routes/auth.routes.js';
import { dashboardRoutes } from './routes/dashboard.routes.js';
import { cfdiRoutes } from './routes/cfdi.routes.js';
const app = express();
// Security
app.use(helmet());
app.use(cors({
origin: env.CORS_ORIGIN,
credentials: true,
}));
// Body parsing
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// API Routes
app.use('/api/auth', authRoutes);
app.use('/api/dashboard', dashboardRoutes);
app.use('/api/cfdi', cfdiRoutes);
// Error handling
app.use(errorMiddleware);
export { app };
Step 5: Commit
git add apps/api/src/
git commit -m "feat(api): add CFDI API endpoints (list, detail, resumen)"
Task 6: Crear API de Impuestos (IVA/ISR)
Files:
- Create:
apps/api/src/services/impuestos.service.ts - Create:
apps/api/src/controllers/impuestos.controller.ts - Create:
apps/api/src/routes/impuestos.routes.ts - Modify:
apps/api/src/app.ts
Step 1: Crear apps/api/src/services/impuestos.service.ts
import { prisma } from '../config/database.js';
import type { IvaMensual, IsrMensual, ResumenIva, ResumenIsr } from '@horux/shared';
export async function getIvaMensual(schema: string, año: number): Promise<IvaMensual[]> {
const data = await prisma.$queryRawUnsafe<IvaMensual[]>(`
SELECT
id, año, mes,
iva_trasladado as "ivaTrasladado",
iva_acreditable as "ivaAcreditable",
COALESCE(iva_retenido, 0) as "ivaRetenido",
resultado, acumulado, estado,
fecha_declaracion as "fechaDeclaracion"
FROM "${schema}".iva_mensual
WHERE año = $1
ORDER BY mes
`, año);
return data.map(row => ({
...row,
ivaTrasladado: Number(row.ivaTrasladado),
ivaAcreditable: Number(row.ivaAcreditable),
ivaRetenido: Number(row.ivaRetenido),
resultado: Number(row.resultado),
acumulado: Number(row.acumulado),
}));
}
export async function getResumenIva(schema: string, año: number, mes: number): Promise<ResumenIva> {
// Get from iva_mensual if exists
const [existing] = await prisma.$queryRawUnsafe<IvaMensual[]>(`
SELECT * FROM "${schema}".iva_mensual WHERE año = $1 AND mes = $2
`, año, mes);
if (existing) {
const [acumuladoResult] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
SELECT COALESCE(SUM(resultado), 0) as total
FROM "${schema}".iva_mensual
WHERE año = $1 AND mes <= $2
`, año, mes);
return {
trasladado: Number(existing.ivaTrasladado || existing.iva_trasladado),
acreditable: Number(existing.ivaAcreditable || existing.iva_acreditable),
retenido: Number(existing.ivaRetenido || existing.iva_retenido || 0),
resultado: Number(existing.resultado),
acumuladoAnual: Number(acumuladoResult?.total || 0),
};
}
// Calculate from CFDIs if no iva_mensual record
const [calcResult] = await prisma.$queryRawUnsafe<[{
trasladado: number;
acreditable: number;
retenido: number;
}]>(`
SELECT
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) as trasladado,
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as acreditable,
COALESCE(SUM(iva_retenido), 0) as retenido
FROM "${schema}".cfdis
WHERE estado = 'vigente'
AND EXTRACT(YEAR FROM fecha_emision) = $1
AND EXTRACT(MONTH FROM fecha_emision) = $2
`, año, mes);
const trasladado = Number(calcResult?.trasladado || 0);
const acreditable = Number(calcResult?.acreditable || 0);
const retenido = Number(calcResult?.retenido || 0);
const resultado = trasladado - acreditable - retenido;
return {
trasladado,
acreditable,
retenido,
resultado,
acumuladoAnual: resultado,
};
}
export async function getIsrMensual(schema: string, año: number): Promise<IsrMensual[]> {
// Check if isr_mensual table exists
try {
const data = await prisma.$queryRawUnsafe<IsrMensual[]>(`
SELECT
id, año, mes,
ingresos_acumulados as "ingresosAcumulados",
deducciones,
base_gravable as "baseGravable",
isr_causado as "isrCausado",
isr_retenido as "isrRetenido",
isr_a_pagar as "isrAPagar",
estado,
fecha_declaracion as "fechaDeclaracion"
FROM "${schema}".isr_mensual
WHERE año = $1
ORDER BY mes
`, año);
return data.map(row => ({
...row,
ingresosAcumulados: Number(row.ingresosAcumulados),
deducciones: Number(row.deducciones),
baseGravable: Number(row.baseGravable),
isrCausado: Number(row.isrCausado),
isrRetenido: Number(row.isrRetenido),
isrAPagar: Number(row.isrAPagar),
}));
} catch {
// Table doesn't exist, return empty array
return [];
}
}
export async function getResumenIsr(schema: string, año: number, mes: number): Promise<ResumenIsr> {
// Calculate from CFDIs
const [ingresos] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
SELECT COALESCE(SUM(total), 0) as total
FROM "${schema}".cfdis
WHERE tipo = 'ingreso' AND estado = 'vigente'
AND EXTRACT(YEAR FROM fecha_emision) = $1
AND EXTRACT(MONTH FROM fecha_emision) <= $2
`, año, mes);
const [egresos] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
SELECT COALESCE(SUM(total), 0) as total
FROM "${schema}".cfdis
WHERE tipo = 'egreso' AND estado = 'vigente'
AND EXTRACT(YEAR FROM fecha_emision) = $1
AND EXTRACT(MONTH FROM fecha_emision) <= $2
`, año, mes);
const [retenido] = await prisma.$queryRawUnsafe<[{ total: number }]>(`
SELECT COALESCE(SUM(isr_retenido), 0) as total
FROM "${schema}".cfdis
WHERE estado = 'vigente'
AND EXTRACT(YEAR FROM fecha_emision) = $1
AND EXTRACT(MONTH FROM fecha_emision) <= $2
`, año, mes);
const ingresosAcumulados = Number(ingresos?.total || 0);
const deducciones = Number(egresos?.total || 0);
const baseGravable = Math.max(0, ingresosAcumulados - deducciones);
// Simplified ISR calculation (actual calculation would use SAT tables)
const isrCausado = baseGravable * 0.30; // 30% simplified rate
const isrRetenido = Number(retenido?.total || 0);
const isrAPagar = Math.max(0, isrCausado - isrRetenido);
return {
ingresosAcumulados,
deducciones,
baseGravable,
isrCausado,
isrRetenido,
isrAPagar,
};
}
Step 2: Crear apps/api/src/controllers/impuestos.controller.ts
import type { Request, Response, NextFunction } from 'express';
import * as impuestosService from '../services/impuestos.service.js';
import { AppError } from '../middlewares/error.middleware.js';
export async function getIvaMensual(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
}
const año = parseInt(req.query.año as string) || new Date().getFullYear();
const data = await impuestosService.getIvaMensual(req.tenantSchema, año);
res.json(data);
} catch (error) {
next(error);
}
}
export async function getResumenIva(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
}
const año = parseInt(req.query.año as string) || new Date().getFullYear();
const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1;
const resumen = await impuestosService.getResumenIva(req.tenantSchema, año, mes);
res.json(resumen);
} catch (error) {
next(error);
}
}
export async function getIsrMensual(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
}
const año = parseInt(req.query.año as string) || new Date().getFullYear();
const data = await impuestosService.getIsrMensual(req.tenantSchema, año);
res.json(data);
} catch (error) {
next(error);
}
}
export async function getResumenIsr(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
}
const año = parseInt(req.query.año as string) || new Date().getFullYear();
const mes = parseInt(req.query.mes as string) || new Date().getMonth() + 1;
const resumen = await impuestosService.getResumenIsr(req.tenantSchema, año, mes);
res.json(resumen);
} catch (error) {
next(error);
}
}
Step 3: Crear apps/api/src/routes/impuestos.routes.ts
import { Router } from 'express';
import { authenticate } from '../middlewares/auth.middleware.js';
import { tenantMiddleware } from '../middlewares/tenant.middleware.js';
import * as impuestosController from '../controllers/impuestos.controller.js';
const router = Router();
router.use(authenticate);
router.use(tenantMiddleware);
router.get('/iva/mensual', impuestosController.getIvaMensual);
router.get('/iva/resumen', impuestosController.getResumenIva);
router.get('/isr/mensual', impuestosController.getIsrMensual);
router.get('/isr/resumen', impuestosController.getResumenIsr);
export { router as impuestosRoutes };
Step 4: Actualizar apps/api/src/app.ts
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import { env } from './config/env.js';
import { errorMiddleware } from './middlewares/error.middleware.js';
import { authRoutes } from './routes/auth.routes.js';
import { dashboardRoutes } from './routes/dashboard.routes.js';
import { cfdiRoutes } from './routes/cfdi.routes.js';
import { impuestosRoutes } from './routes/impuestos.routes.js';
const app = express();
// Security
app.use(helmet());
app.use(cors({
origin: env.CORS_ORIGIN,
credentials: true,
}));
// Body parsing
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
// API Routes
app.use('/api/auth', authRoutes);
app.use('/api/dashboard', dashboardRoutes);
app.use('/api/cfdi', cfdiRoutes);
app.use('/api/impuestos', impuestosRoutes);
// Error handling
app.use(errorMiddleware);
export { app };
Step 5: Commit
git add apps/api/src/
git commit -m "feat(api): add impuestos API endpoints (IVA/ISR mensual y resumen)"
Task 7: Crear Layout del Dashboard (Frontend)
Files:
- Create:
apps/web/app/(dashboard)/layout.tsx - Create:
apps/web/components/layouts/sidebar.tsx - Create:
apps/web/components/layouts/header.tsx - Create:
apps/web/components/layouts/dashboard-shell.tsx
Step 1: Crear apps/web/components/layouts/sidebar.tsx
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
import {
LayoutDashboard,
FileText,
Calculator,
Settings,
LogOut,
} from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store';
import { logout } from '@/lib/api/auth';
import { useRouter } from 'next/navigation';
const navigation = [
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
{ name: 'CFDI', href: '/cfdi', icon: FileText },
{ name: 'Impuestos', href: '/impuestos', icon: Calculator },
{ name: 'Configuración', href: '/configuracion', icon: Settings },
];
export function Sidebar() {
const pathname = usePathname();
const router = useRouter();
const { user, logout: clearAuth } = useAuthStore();
const handleLogout = async () => {
try {
await logout();
} catch {
// Ignore errors
} finally {
clearAuth();
router.push('/login');
}
};
return (
<aside className="fixed left-0 top-0 z-40 h-screen w-64 border-r bg-card">
<div className="flex h-full flex-col">
{/* Logo */}
<div className="flex h-16 items-center border-b px-6">
<Link href="/dashboard" className="flex items-center gap-2">
<div className="h-8 w-8 rounded-lg bg-primary flex items-center justify-center">
<span className="text-primary-foreground font-bold text-lg">H</span>
</div>
<span className="font-bold text-xl">Horux360</span>
</Link>
</div>
{/* Navigation */}
<nav className="flex-1 space-y-1 px-3 py-4">
{navigation.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(`${item.href}/`);
return (
<Link
key={item.name}
href={item.href}
className={cn(
'flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)}
>
<item.icon className="h-5 w-5" />
{item.name}
</Link>
);
})}
</nav>
{/* User & Logout */}
<div className="border-t p-4">
<div className="mb-3 px-3">
<p className="text-sm font-medium">{user?.nombre}</p>
<p className="text-xs text-muted-foreground">{user?.email}</p>
</div>
<button
onClick={handleLogout}
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-destructive hover:text-destructive-foreground transition-colors"
>
<LogOut className="h-5 w-5" />
Cerrar sesión
</button>
</div>
</div>
</aside>
);
}
Step 2: Crear apps/web/components/layouts/header.tsx
'use client';
import { useThemeStore } from '@/stores/theme-store';
import { themes, type ThemeName } from '@/themes';
import { Button } from '@/components/ui/button';
import { Sun, Moon, Palette } from 'lucide-react';
const themeIcons: Record<ThemeName, React.ReactNode> = {
light: <Sun className="h-4 w-4" />,
vibrant: <Palette className="h-4 w-4" />,
corporate: <Palette className="h-4 w-4" />,
dark: <Moon className="h-4 w-4" />,
};
const themeOrder: ThemeName[] = ['light', 'vibrant', 'corporate', 'dark'];
export function Header({ title }: { title: string }) {
const { theme, setTheme } = useThemeStore();
const cycleTheme = () => {
const currentIndex = themeOrder.indexOf(theme);
const nextIndex = (currentIndex + 1) % themeOrder.length;
setTheme(themeOrder[nextIndex]);
};
return (
<header className="sticky top-0 z-30 flex h-16 items-center justify-between border-b bg-background/95 backdrop-blur px-6">
<h1 className="text-xl font-semibold">{title}</h1>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
onClick={cycleTheme}
title={`Tema: ${themes[theme].name}`}
>
{themeIcons[theme]}
</Button>
</div>
</header>
);
}
Step 3: Crear apps/web/components/layouts/dashboard-shell.tsx
import { Sidebar } from './sidebar';
import { Header } from './header';
interface DashboardShellProps {
children: React.ReactNode;
title: string;
}
export function DashboardShell({ children, title }: DashboardShellProps) {
return (
<div className="min-h-screen bg-background">
<Sidebar />
<div className="pl-64">
<Header title={title} />
<main className="p-6">{children}</main>
</div>
</div>
);
}
Step 4: Crear apps/web/app/(dashboard)/layout.tsx
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/auth-store';
import { Sidebar } from '@/components/layouts/sidebar';
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const { isAuthenticated } = useAuthStore();
useEffect(() => {
if (!isAuthenticated) {
router.push('/login');
}
}, [isAuthenticated, router]);
if (!isAuthenticated) {
return null;
}
return (
<div className="min-h-screen bg-background">
<Sidebar />
<div className="pl-64">
{children}
</div>
</div>
);
}
Step 5: Commit
git add apps/web/components/layouts apps/web/app/\(dashboard\)/layout.tsx
git commit -m "feat(web): add dashboard layout with sidebar and header"
Task 8: Crear Componentes de Gráficos
Files:
- Create:
apps/web/components/charts/bar-chart.tsx - Create:
apps/web/components/charts/kpi-card.tsx - Create:
apps/web/components/charts/index.ts
Step 1: Crear apps/web/components/charts/kpi-card.tsx
import { Card, CardContent } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { TrendingUp, TrendingDown, Minus } from 'lucide-react';
interface KpiCardProps {
title: string;
value: string | number;
subtitle?: string;
trend?: 'up' | 'down' | 'neutral';
trendValue?: string;
icon?: React.ReactNode;
className?: string;
}
export function KpiCard({
title,
value,
subtitle,
trend,
trendValue,
icon,
className,
}: KpiCardProps) {
const formatValue = (val: string | number) => {
if (typeof val === 'number') {
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(val);
}
return val;
};
return (
<Card className={cn('', className)}>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<p className="text-sm font-medium text-muted-foreground">{title}</p>
{icon && <div className="text-muted-foreground">{icon}</div>}
</div>
<div className="mt-2">
<p className="text-2xl font-bold">{formatValue(value)}</p>
{(subtitle || trend) && (
<div className="mt-1 flex items-center gap-2">
{trend && (
<span
className={cn(
'flex items-center text-xs font-medium',
trend === 'up' && 'text-success',
trend === 'down' && 'text-destructive',
trend === 'neutral' && 'text-muted-foreground'
)}
>
{trend === 'up' && <TrendingUp className="mr-1 h-3 w-3" />}
{trend === 'down' && <TrendingDown className="mr-1 h-3 w-3" />}
{trend === 'neutral' && <Minus className="mr-1 h-3 w-3" />}
{trendValue}
</span>
)}
{subtitle && (
<span className="text-xs text-muted-foreground">{subtitle}</span>
)}
</div>
)}
</div>
</CardContent>
</Card>
);
}
Step 2: Crear apps/web/components/charts/bar-chart.tsx
'use client';
import {
BarChart as RechartsBarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from 'recharts';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
interface BarChartProps {
title: string;
data: { mes: string; ingresos: number; egresos: number }[];
}
const formatCurrency = (value: number) => {
if (value >= 1000000) {
return `$${(value / 1000000).toFixed(1)}M`;
}
if (value >= 1000) {
return `$${(value / 1000).toFixed(0)}K`;
}
return `$${value}`;
};
export function BarChart({ title, data }: BarChartProps) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base font-medium">{title}</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<RechartsBarChart data={data} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="mes"
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
className="text-muted-foreground"
/>
<YAxis
tickFormatter={formatCurrency}
tick={{ fontSize: 12 }}
tickLine={false}
axisLine={false}
className="text-muted-foreground"
/>
<Tooltip
formatter={(value: number) =>
new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
}).format(value)
}
contentStyle={{
backgroundColor: 'hsl(var(--card))',
border: '1px solid hsl(var(--border))',
borderRadius: '8px',
}}
/>
<Legend />
<Bar
dataKey="ingresos"
name="Ingresos"
fill="hsl(var(--success))"
radius={[4, 4, 0, 0]}
/>
<Bar
dataKey="egresos"
name="Egresos"
fill="hsl(var(--destructive))"
radius={[4, 4, 0, 0]}
/>
</RechartsBarChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
);
}
Step 3: Crear apps/web/components/charts/index.ts
export { KpiCard } from './kpi-card';
export { BarChart } from './bar-chart';
Step 4: Commit
git add apps/web/components/charts/
git commit -m "feat(web): add chart components (KpiCard, BarChart)"
Task 9: Crear Hooks y API Client para Dashboard
Files:
- Create:
apps/web/lib/api/dashboard.ts - Create:
apps/web/lib/hooks/use-dashboard.ts
Step 1: Crear apps/web/lib/api/dashboard.ts
import { apiClient } from './client';
import type { KpiData, IngresosEgresosData, ResumenFiscal, Alerta } from '@horux/shared';
export async function getKpis(año?: number, mes?: number): Promise<KpiData> {
const params = new URLSearchParams();
if (año) params.set('año', año.toString());
if (mes) params.set('mes', mes.toString());
const response = await apiClient.get<KpiData>(`/dashboard/kpis?${params}`);
return response.data;
}
export async function getIngresosEgresos(año?: number): Promise<IngresosEgresosData[]> {
const params = new URLSearchParams();
if (año) params.set('año', año.toString());
const response = await apiClient.get<IngresosEgresosData[]>(`/dashboard/ingresos-egresos?${params}`);
return response.data;
}
export async function getResumenFiscal(año?: number, mes?: number): Promise<ResumenFiscal> {
const params = new URLSearchParams();
if (año) params.set('año', año.toString());
if (mes) params.set('mes', mes.toString());
const response = await apiClient.get<ResumenFiscal>(`/dashboard/resumen-fiscal?${params}`);
return response.data;
}
export async function getAlertas(limit = 5): Promise<Alerta[]> {
const response = await apiClient.get<Alerta[]>(`/dashboard/alertas?limit=${limit}`);
return response.data;
}
Step 2: Crear apps/web/lib/hooks/use-dashboard.ts
import { useQuery } from '@tanstack/react-query';
import * as dashboardApi from '@/lib/api/dashboard';
export function useKpis(año?: number, mes?: number) {
return useQuery({
queryKey: ['kpis', año, mes],
queryFn: () => dashboardApi.getKpis(año, mes),
});
}
export function useIngresosEgresos(año?: number) {
return useQuery({
queryKey: ['ingresos-egresos', año],
queryFn: () => dashboardApi.getIngresosEgresos(año),
});
}
export function useResumenFiscal(año?: number, mes?: number) {
return useQuery({
queryKey: ['resumen-fiscal', año, mes],
queryFn: () => dashboardApi.getResumenFiscal(año, mes),
});
}
export function useAlertas(limit = 5) {
return useQuery({
queryKey: ['alertas', limit],
queryFn: () => dashboardApi.getAlertas(limit),
});
}
Step 3: Commit
git add apps/web/lib/api/dashboard.ts apps/web/lib/hooks/
git commit -m "feat(web): add dashboard API client and hooks"
Task 10: Crear Página del Dashboard
Files:
- Create:
apps/web/app/(dashboard)/dashboard/page.tsx - Create:
apps/web/components/providers/query-provider.tsx - Modify:
apps/web/app/layout.tsx
Step 1: Crear apps/web/components/providers/query-provider.tsx
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
refetchOnWindowFocus: false,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
Step 2: Actualizar apps/web/app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { ThemeProvider } from '@/components/providers/theme-provider';
import { QueryProvider } from '@/components/providers/query-provider';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'Horux360 - Análisis Financiero',
description: 'Plataforma de análisis financiero y gestión fiscal para empresas mexicanas',
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="es" suppressHydrationWarning>
<body className={inter.className}>
<QueryProvider>
<ThemeProvider>{children}</ThemeProvider>
</QueryProvider>
</body>
</html>
);
}
Step 3: Crear apps/web/app/(dashboard)/dashboard/page.tsx
'use client';
import { Header } from '@/components/layouts/header';
import { KpiCard } from '@/components/charts/kpi-card';
import { BarChart } from '@/components/charts/bar-chart';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { useKpis, useIngresosEgresos, useAlertas, useResumenFiscal } from '@/lib/hooks/use-dashboard';
import {
TrendingUp,
TrendingDown,
Wallet,
Receipt,
FileText,
AlertTriangle,
} from 'lucide-react';
export default function DashboardPage() {
const currentYear = new Date().getFullYear();
const currentMonth = new Date().getMonth() + 1;
const { data: kpis, isLoading: kpisLoading } = useKpis(currentYear, currentMonth);
const { data: chartData, isLoading: chartLoading } = useIngresosEgresos(currentYear);
const { data: alertas, isLoading: alertasLoading } = useAlertas(5);
const { data: resumenFiscal } = useResumenFiscal(currentYear, currentMonth);
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
minimumFractionDigits: 0,
}).format(value);
return (
<>
<Header title="Dashboard" />
<main className="p-6 space-y-6">
{/* KPIs */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<KpiCard
title="Ingresos del Mes"
value={kpis?.ingresos || 0}
icon={<TrendingUp className="h-4 w-4" />}
trend="up"
trendValue="+12.5%"
subtitle="vs mes anterior"
/>
<KpiCard
title="Egresos del Mes"
value={kpis?.egresos || 0}
icon={<TrendingDown className="h-4 w-4" />}
trend="down"
trendValue="-3.2%"
subtitle="vs mes anterior"
/>
<KpiCard
title="Utilidad"
value={kpis?.utilidad || 0}
icon={<Wallet className="h-4 w-4" />}
trend={kpis?.utilidad && kpis.utilidad > 0 ? 'up' : 'down'}
trendValue={`${kpis?.margen || 0}% margen`}
/>
<KpiCard
title="Balance IVA"
value={kpis?.ivaBalance || 0}
icon={<Receipt className="h-4 w-4" />}
trend={kpis?.ivaBalance && kpis.ivaBalance > 0 ? 'up' : 'down'}
trendValue={kpis?.ivaBalance && kpis.ivaBalance > 0 ? 'Por pagar' : 'A favor'}
/>
</div>
{/* Charts and Alerts */}
<div className="grid gap-6 lg:grid-cols-3">
<div className="lg:col-span-2">
<BarChart
title="Ingresos vs Egresos"
data={chartData || []}
/>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base font-medium">
<AlertTriangle className="h-4 w-4" />
Alertas
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{alertasLoading ? (
<p className="text-sm text-muted-foreground">Cargando...</p>
) : alertas?.length === 0 ? (
<p className="text-sm text-muted-foreground">No hay alertas pendientes</p>
) : (
alertas?.map((alerta) => (
<div
key={alerta.id}
className={`p-3 rounded-lg border ${
alerta.prioridad === 'alta'
? 'border-destructive/50 bg-destructive/10'
: 'border-border bg-muted/50'
}`}
>
<p className="text-sm font-medium">{alerta.titulo}</p>
<p className="text-xs text-muted-foreground mt-1">
{alerta.mensaje}
</p>
</div>
))
)}
</CardContent>
</Card>
</div>
{/* Resumen Fiscal */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">CFDIs Emitidos</p>
<p className="text-2xl font-bold">{kpis?.cfdisEmitidos || 0}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">CFDIs Recibidos</p>
<p className="text-2xl font-bold">{kpis?.cfdisRecibidos || 0}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">IVA a Favor Acumulado</p>
<p className="text-2xl font-bold text-success">
{formatCurrency(resumenFiscal?.ivaAFavor || 0)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-sm text-muted-foreground">Declaraciones Pendientes</p>
<p className="text-2xl font-bold">
{resumenFiscal?.declaracionesPendientes || 0}
</p>
</CardContent>
</Card>
</div>
</main>
</>
);
}
Step 4: Commit
git add apps/web/
git commit -m "feat(web): add dashboard page with KPIs, charts, and alerts"
Task 11: Crear Página de CFDI
Files:
- Create:
apps/web/lib/api/cfdi.ts - Create:
apps/web/lib/hooks/use-cfdi.ts - Create:
apps/web/app/(dashboard)/cfdi/page.tsx
Step 1: Crear apps/web/lib/api/cfdi.ts
import { apiClient } from './client';
import type { CfdiListResponse, CfdiFilters, Cfdi } from '@horux/shared';
export async function getCfdis(filters: CfdiFilters): Promise<CfdiListResponse> {
const params = new URLSearchParams();
if (filters.tipo) params.set('tipo', filters.tipo);
if (filters.estado) params.set('estado', filters.estado);
if (filters.fechaInicio) params.set('fechaInicio', filters.fechaInicio);
if (filters.fechaFin) params.set('fechaFin', filters.fechaFin);
if (filters.rfc) params.set('rfc', filters.rfc);
if (filters.search) params.set('search', filters.search);
if (filters.page) params.set('page', filters.page.toString());
if (filters.limit) params.set('limit', filters.limit.toString());
const response = await apiClient.get<CfdiListResponse>(`/cfdi?${params}`);
return response.data;
}
export async function getCfdiById(id: string): Promise<Cfdi> {
const response = await apiClient.get<Cfdi>(`/cfdi/${id}`);
return response.data;
}
export async function getResumenCfdi(año?: number, mes?: number) {
const params = new URLSearchParams();
if (año) params.set('año', año.toString());
if (mes) params.set('mes', mes.toString());
const response = await apiClient.get(`/cfdi/resumen?${params}`);
return response.data;
}
Step 2: Crear apps/web/lib/hooks/use-cfdi.ts
import { useQuery } from '@tanstack/react-query';
import * as cfdiApi from '@/lib/api/cfdi';
import type { CfdiFilters } from '@horux/shared';
export function useCfdis(filters: CfdiFilters) {
return useQuery({
queryKey: ['cfdis', filters],
queryFn: () => cfdiApi.getCfdis(filters),
});
}
export function useCfdi(id: string) {
return useQuery({
queryKey: ['cfdi', id],
queryFn: () => cfdiApi.getCfdiById(id),
enabled: !!id,
});
}
export function useResumenCfdi(año?: number, mes?: number) {
return useQuery({
queryKey: ['cfdi-resumen', año, mes],
queryFn: () => cfdiApi.getResumenCfdi(año, mes),
});
}
Step 3: Crear apps/web/app/(dashboard)/cfdi/page.tsx
'use client';
import { useState } from 'react';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useCfdis } from '@/lib/hooks/use-cfdi';
import type { CfdiFilters, TipoCfdi } from '@horux/shared';
import { FileText, Search, ChevronLeft, ChevronRight } from 'lucide-react';
export default function CfdiPage() {
const [filters, setFilters] = useState<CfdiFilters>({
page: 1,
limit: 20,
});
const [searchTerm, setSearchTerm] = useState('');
const { data, isLoading } = useCfdis(filters);
const handleSearch = () => {
setFilters({ ...filters, search: searchTerm, page: 1 });
};
const handleFilterType = (tipo?: TipoCfdi) => {
setFilters({ ...filters, tipo, page: 1 });
};
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
}).format(value);
const formatDate = (dateString: string) =>
new Date(dateString).toLocaleDateString('es-MX', {
day: '2-digit',
month: 'short',
year: 'numeric',
});
return (
<>
<Header title="Gestión de CFDI" />
<main className="p-6 space-y-6">
{/* Filters */}
<Card>
<CardContent className="p-4">
<div className="flex flex-wrap gap-4">
<div className="flex gap-2 flex-1 min-w-[300px]">
<Input
placeholder="Buscar por UUID, RFC o nombre..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
/>
<Button onClick={handleSearch}>
<Search className="h-4 w-4" />
</Button>
</div>
<div className="flex gap-2">
<Button
variant={filters.tipo === undefined ? 'default' : 'outline'}
size="sm"
onClick={() => handleFilterType(undefined)}
>
Todos
</Button>
<Button
variant={filters.tipo === 'ingreso' ? 'default' : 'outline'}
size="sm"
onClick={() => handleFilterType('ingreso')}
>
Ingresos
</Button>
<Button
variant={filters.tipo === 'egreso' ? 'default' : 'outline'}
size="sm"
onClick={() => handleFilterType('egreso')}
>
Egresos
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Table */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="h-4 w-4" />
CFDIs ({data?.total || 0})
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">
Cargando...
</div>
) : data?.data.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No se encontraron CFDIs
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b text-left text-sm text-muted-foreground">
<th className="pb-3 font-medium">Fecha</th>
<th className="pb-3 font-medium">Tipo</th>
<th className="pb-3 font-medium">Serie/Folio</th>
<th className="pb-3 font-medium">Emisor/Receptor</th>
<th className="pb-3 font-medium text-right">Total</th>
<th className="pb-3 font-medium">Estado</th>
</tr>
</thead>
<tbody className="text-sm">
{data?.data.map((cfdi) => (
<tr key={cfdi.id} className="border-b hover:bg-muted/50">
<td className="py-3">{formatDate(cfdi.fechaEmision)}</td>
<td className="py-3">
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${
cfdi.tipo === 'ingreso'
? 'bg-success/10 text-success'
: 'bg-destructive/10 text-destructive'
}`}
>
{cfdi.tipo === 'ingreso' ? 'Ingreso' : 'Egreso'}
</span>
</td>
<td className="py-3">
{cfdi.serie || '-'}-{cfdi.folio || '-'}
</td>
<td className="py-3">
<div>
<p className="font-medium">
{cfdi.tipo === 'ingreso'
? cfdi.nombreReceptor
: cfdi.nombreEmisor}
</p>
<p className="text-xs text-muted-foreground">
{cfdi.tipo === 'ingreso'
? cfdi.rfcReceptor
: cfdi.rfcEmisor}
</p>
</div>
</td>
<td className="py-3 text-right font-medium">
{formatCurrency(cfdi.total)}
</td>
<td className="py-3">
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${
cfdi.estado === 'vigente'
? 'bg-success/10 text-success'
: 'bg-muted text-muted-foreground'
}`}
>
{cfdi.estado === 'vigente' ? 'Vigente' : 'Cancelado'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* Pagination */}
{data && data.totalPages > 1 && (
<div className="flex items-center justify-between mt-4 pt-4 border-t">
<p className="text-sm text-muted-foreground">
Página {data.page} de {data.totalPages}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={data.page <= 1}
onClick={() =>
setFilters({ ...filters, page: (filters.page || 1) - 1 })
}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
disabled={data.page >= data.totalPages}
onClick={() =>
setFilters({ ...filters, page: (filters.page || 1) + 1 })
}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</main>
</>
);
}
Step 4: Commit
git add apps/web/
git commit -m "feat(web): add CFDI page with list, filters, and pagination"
Task 12: Crear Página de Impuestos
Files:
- Create:
apps/web/lib/api/impuestos.ts - Create:
apps/web/lib/hooks/use-impuestos.ts - Create:
apps/web/app/(dashboard)/impuestos/page.tsx
Step 1: Crear apps/web/lib/api/impuestos.ts
import { apiClient } from './client';
import type { IvaMensual, IsrMensual, ResumenIva, ResumenIsr } from '@horux/shared';
export async function getIvaMensual(año?: number): Promise<IvaMensual[]> {
const params = año ? `?año=${año}` : '';
const response = await apiClient.get<IvaMensual[]>(`/impuestos/iva/mensual${params}`);
return response.data;
}
export async function getResumenIva(año?: number, mes?: number): Promise<ResumenIva> {
const params = new URLSearchParams();
if (año) params.set('año', año.toString());
if (mes) params.set('mes', mes.toString());
const response = await apiClient.get<ResumenIva>(`/impuestos/iva/resumen?${params}`);
return response.data;
}
export async function getIsrMensual(año?: number): Promise<IsrMensual[]> {
const params = año ? `?año=${año}` : '';
const response = await apiClient.get<IsrMensual[]>(`/impuestos/isr/mensual${params}`);
return response.data;
}
export async function getResumenIsr(año?: number, mes?: number): Promise<ResumenIsr> {
const params = new URLSearchParams();
if (año) params.set('año', año.toString());
if (mes) params.set('mes', mes.toString());
const response = await apiClient.get<ResumenIsr>(`/impuestos/isr/resumen?${params}`);
return response.data;
}
Step 2: Crear apps/web/lib/hooks/use-impuestos.ts
import { useQuery } from '@tanstack/react-query';
import * as impuestosApi from '@/lib/api/impuestos';
export function useIvaMensual(año?: number) {
return useQuery({
queryKey: ['iva-mensual', año],
queryFn: () => impuestosApi.getIvaMensual(año),
});
}
export function useResumenIva(año?: number, mes?: number) {
return useQuery({
queryKey: ['iva-resumen', año, mes],
queryFn: () => impuestosApi.getResumenIva(año, mes),
});
}
export function useIsrMensual(año?: number) {
return useQuery({
queryKey: ['isr-mensual', año],
queryFn: () => impuestosApi.getIsrMensual(año),
});
}
export function useResumenIsr(año?: number, mes?: number) {
return useQuery({
queryKey: ['isr-resumen', año, mes],
queryFn: () => impuestosApi.getResumenIsr(año, mes),
});
}
Step 3: Crear apps/web/app/(dashboard)/impuestos/page.tsx
'use client';
import { useState } from 'react';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { KpiCard } from '@/components/charts/kpi-card';
import { useIvaMensual, useResumenIva, useResumenIsr } from '@/lib/hooks/use-impuestos';
import { Calculator, TrendingUp, TrendingDown, Receipt } from 'lucide-react';
const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
export default function ImpuestosPage() {
const currentYear = new Date().getFullYear();
const currentMonth = new Date().getMonth() + 1;
const [año] = useState(currentYear);
const [activeTab, setActiveTab] = useState<'iva' | 'isr'>('iva');
const { data: ivaMensual, isLoading: ivaLoading } = useIvaMensual(año);
const { data: resumenIva } = useResumenIva(año, currentMonth);
const { data: resumenIsr } = useResumenIsr(año, currentMonth);
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
minimumFractionDigits: 0,
}).format(value);
return (
<>
<Header title="Control de Impuestos" />
<main className="p-6 space-y-6">
{/* Tabs */}
<div className="flex gap-2">
<Button
variant={activeTab === 'iva' ? 'default' : 'outline'}
onClick={() => setActiveTab('iva')}
>
<Receipt className="h-4 w-4 mr-2" />
IVA
</Button>
<Button
variant={activeTab === 'isr' ? 'default' : 'outline'}
onClick={() => setActiveTab('isr')}
>
<Calculator className="h-4 w-4 mr-2" />
ISR
</Button>
</div>
{activeTab === 'iva' && (
<>
{/* IVA KPIs */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<KpiCard
title="IVA Trasladado"
value={resumenIva?.trasladado || 0}
icon={<TrendingUp className="h-4 w-4" />}
subtitle="Cobrado a clientes"
/>
<KpiCard
title="IVA Acreditable"
value={resumenIva?.acreditable || 0}
icon={<TrendingDown className="h-4 w-4" />}
subtitle="Pagado a proveedores"
/>
<KpiCard
title="Resultado del Mes"
value={resumenIva?.resultado || 0}
icon={<Calculator className="h-4 w-4" />}
trend={(resumenIva?.resultado || 0) > 0 ? 'up' : 'down'}
trendValue={(resumenIva?.resultado || 0) > 0 ? 'Por pagar' : 'A favor'}
/>
<KpiCard
title="Acumulado Anual"
value={resumenIva?.acumuladoAnual || 0}
icon={<Receipt className="h-4 w-4" />}
trend={(resumenIva?.acumuladoAnual || 0) < 0 ? 'up' : 'neutral'}
trendValue={(resumenIva?.acumuladoAnual || 0) < 0 ? 'Saldo a favor' : ''}
/>
</div>
{/* IVA Mensual Table */}
<Card>
<CardHeader>
<CardTitle className="text-base">Histórico IVA {año}</CardTitle>
</CardHeader>
<CardContent>
{ivaLoading ? (
<div className="text-center py-8 text-muted-foreground">
Cargando...
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b text-left text-sm text-muted-foreground">
<th className="pb-3 font-medium">Mes</th>
<th className="pb-3 font-medium text-right">Trasladado</th>
<th className="pb-3 font-medium text-right">Acreditable</th>
<th className="pb-3 font-medium text-right">Retenido</th>
<th className="pb-3 font-medium text-right">Resultado</th>
<th className="pb-3 font-medium text-right">Acumulado</th>
<th className="pb-3 font-medium">Estado</th>
</tr>
</thead>
<tbody className="text-sm">
{ivaMensual?.map((row) => (
<tr key={row.mes} className="border-b hover:bg-muted/50">
<td className="py-3 font-medium">{meses[row.mes - 1]}</td>
<td className="py-3 text-right">
{formatCurrency(row.ivaTrasladado)}
</td>
<td className="py-3 text-right">
{formatCurrency(row.ivaAcreditable)}
</td>
<td className="py-3 text-right">
{formatCurrency(row.ivaRetenido)}
</td>
<td
className={`py-3 text-right font-medium ${
row.resultado > 0
? 'text-destructive'
: 'text-success'
}`}
>
{formatCurrency(row.resultado)}
</td>
<td
className={`py-3 text-right font-medium ${
row.acumulado > 0
? 'text-destructive'
: 'text-success'
}`}
>
{formatCurrency(row.acumulado)}
</td>
<td className="py-3">
<span
className={`px-2 py-1 rounded-full text-xs font-medium ${
row.estado === 'declarado'
? 'bg-success/10 text-success'
: 'bg-warning/10 text-warning'
}`}
>
{row.estado === 'declarado' ? 'Declarado' : 'Pendiente'}
</span>
</td>
</tr>
))}
{(!ivaMensual || ivaMensual.length === 0) && (
<tr>
<td colSpan={7} className="py-8 text-center text-muted-foreground">
No hay registros de IVA para este año
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
</>
)}
{activeTab === 'isr' && (
<>
{/* ISR KPIs */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<KpiCard
title="Ingresos Acumulados"
value={resumenIsr?.ingresosAcumulados || 0}
icon={<TrendingUp className="h-4 w-4" />}
/>
<KpiCard
title="Deducciones"
value={resumenIsr?.deducciones || 0}
icon={<TrendingDown className="h-4 w-4" />}
/>
<KpiCard
title="Base Gravable"
value={resumenIsr?.baseGravable || 0}
icon={<Calculator className="h-4 w-4" />}
/>
<KpiCard
title="ISR a Pagar"
value={resumenIsr?.isrAPagar || 0}
icon={<Receipt className="h-4 w-4" />}
trend={(resumenIsr?.isrAPagar || 0) > 0 ? 'up' : 'neutral'}
/>
</div>
{/* ISR Info Card */}
<Card>
<CardHeader>
<CardTitle className="text-base">Cálculo de ISR Acumulado</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">Ingresos acumulados</span>
<span className="font-medium">
{formatCurrency(resumenIsr?.ingresosAcumulados || 0)}
</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">(-) Deducciones autorizadas</span>
<span className="font-medium">
{formatCurrency(resumenIsr?.deducciones || 0)}
</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">(=) Base gravable</span>
<span className="font-medium">
{formatCurrency(resumenIsr?.baseGravable || 0)}
</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">ISR causado (estimado)</span>
<span className="font-medium">
{formatCurrency(resumenIsr?.isrCausado || 0)}
</span>
</div>
<div className="flex justify-between py-2 border-b">
<span className="text-muted-foreground">(-) ISR retenido</span>
<span className="font-medium">
{formatCurrency(resumenIsr?.isrRetenido || 0)}
</span>
</div>
<div className="flex justify-between py-2 bg-muted/50 px-4 rounded-lg">
<span className="font-medium">ISR a pagar</span>
<span className="font-bold text-lg">
{formatCurrency(resumenIsr?.isrAPagar || 0)}
</span>
</div>
</div>
</CardContent>
</Card>
</>
)}
</main>
</>
);
}
Step 4: Commit
git add apps/web/
git commit -m "feat(web): add impuestos page with IVA/ISR control"
Task 13: Crear Página de Configuración
Files:
- Create:
apps/web/app/(dashboard)/configuracion/page.tsx
Step 1: Crear página de configuración
'use client';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { useThemeStore } from '@/stores/theme-store';
import { useAuthStore } from '@/stores/auth-store';
import { themes, type ThemeName } from '@/themes';
import { Check, Palette, User, Building } from 'lucide-react';
const themeOptions: { name: ThemeName; label: string; description: string }[] = [
{ name: 'light', label: 'Light', description: 'Tema claro profesional' },
{ name: 'vibrant', label: 'Vibrant', description: 'Colores vivos y modernos' },
{ name: 'corporate', label: 'Corporate', description: 'Diseño empresarial denso' },
{ name: 'dark', label: 'Dark', description: 'Modo oscuro con acentos neón' },
];
export default function ConfiguracionPage() {
const { theme, setTheme } = useThemeStore();
const { user } = useAuthStore();
return (
<>
<Header title="Configuración" />
<main className="p-6 space-y-6">
{/* User Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<User className="h-4 w-4" />
Información del Usuario
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div>
<p className="text-sm text-muted-foreground">Nombre</p>
<p className="font-medium">{user?.nombre}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Email</p>
<p className="font-medium">{user?.email}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Rol</p>
<p className="font-medium capitalize">{user?.role}</p>
</div>
</div>
</CardContent>
</Card>
{/* Company Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Building className="h-4 w-4" />
Información de la Empresa
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div>
<p className="text-sm text-muted-foreground">Empresa</p>
<p className="font-medium">{user?.tenantNombre}</p>
</div>
<div>
<p className="text-sm text-muted-foreground">Plan</p>
<p className="font-medium capitalize">{user?.plan}</p>
</div>
</div>
</CardContent>
</Card>
{/* Theme Selection */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Palette className="h-4 w-4" />
Tema Visual
</CardTitle>
<CardDescription>
Elige el tema que mejor se adapte a tu preferencia
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{themeOptions.map((option) => (
<button
key={option.name}
onClick={() => setTheme(option.name)}
className={`relative p-4 rounded-lg border-2 text-left transition-all ${
theme === option.name
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
}`}
>
{theme === option.name && (
<div className="absolute top-2 right-2">
<Check className="h-4 w-4 text-primary" />
</div>
)}
<div
className="h-20 rounded-md mb-3"
style={{
background: themes[option.name].colors.primary,
}}
/>
<p className="font-medium">{option.label}</p>
<p className="text-xs text-muted-foreground">
{option.description}
</p>
</button>
))}
</div>
</CardContent>
</Card>
</main>
</>
);
}
Step 2: Commit
git add apps/web/app/\(dashboard\)/configuracion/
git commit -m "feat(web): add configuracion page with theme selector"
Task 14: Actualizar Home Page y Redirect
Files:
- Modify:
apps/web/app/page.tsx
Step 1: Actualizar apps/web/app/page.tsx
import { redirect } from 'next/navigation';
export default function Home() {
redirect('/dashboard');
}
Step 2: Commit
git add apps/web/app/page.tsx
git commit -m "feat(web): redirect home to dashboard"
Task 15: Agregar Lucide Icons y Finalizar
Files:
- Verify all imports and add missing CSS variables
Step 1: Verificar que lucide-react está en package.json
Ya está incluido en apps/web/package.json
Step 2: Agregar variables CSS faltantes a globals.css
Agregar en apps/web/app/globals.css después de las variables existentes:
/* Add to :root */
--warning: 38 92% 50%;
--warning-foreground: 0 0% 100%;
Step 3: Commit final
git add -A
git commit -m "chore: Phase 2 complete - Dashboard, CFDI, Impuestos modules"
git push origin main
Summary
Phase 2 establishes:
- Dashboard with KPIs (ingresos, egresos, utilidad, IVA balance)
- Interactive bar chart (Ingresos vs Egresos)
- Alerts widget
- CFDI management (list, filters, pagination)
- IVA control (monthly history, calculations)
- ISR control (accumulated calculations)
- Theme selector in configuration
- Protected dashboard layout with sidebar
API Endpoints Created:
- GET
/api/dashboard/kpis - GET
/api/dashboard/ingresos-egresos - GET
/api/dashboard/resumen-fiscal - GET
/api/dashboard/alertas - GET
/api/cfdi - GET
/api/cfdi/:id - GET
/api/cfdi/resumen - GET
/api/impuestos/iva/mensual - GET
/api/impuestos/iva/resumen - GET
/api/impuestos/isr/mensual - GET
/api/impuestos/isr/resumen
Next Phase (3): Reportes, Exportación Excel/PDF, Sistema de alertas, Calendario fiscal, Gestión de usuarios.