- 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>
2733 lines
83 KiB
Markdown
2733 lines
83 KiB
Markdown
# 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<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**
|
|
|
|
```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<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**
|
|
|
|
```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<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**
|
|
|
|
```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 (
|
|
<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**
|
|
|
|
```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**
|
|
|
|
```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**
|
|
|
|
```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**
|
|
|
|
```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 (
|
|
<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**
|
|
|
|
```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**
|
|
|
|
```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<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**
|
|
|
|
```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 (
|
|
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
|
);
|
|
}
|
|
```
|
|
|
|
**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 (
|
|
<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**
|
|
|
|
```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**
|
|
|
|
```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<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**
|
|
|
|
```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<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**
|
|
|
|
```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<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**
|
|
|
|
```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 (
|
|
<>
|
|
<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**
|
|
|
|
```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 (
|
|
<>
|
|
<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**
|
|
|
|
```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.
|