feat: bulk XML upload, period selector, and session persistence

- Add bulk XML CFDI upload support (up to 300MB)
- Add period selector component for month/year navigation
- Fix session persistence on page refresh (Zustand hydration)
- Fix income/expense classification based on tenant RFC
- Fix IVA calculation from XML (correct Impuestos element)
- Add error handling to reportes page
- Support multiple CORS origins
- Update reportes service with proper Decimal/BigInt handling
- Add RFC to tenant view store for proper CFDI classification
- Update README with changelog and new features

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Consultoria AS
2026-01-22 06:51:53 +00:00
parent 0c10c887d2
commit c3ce7199af
37 changed files with 1680 additions and 216 deletions

View File

@@ -19,6 +19,7 @@ Horux360 es una aplicación SaaS que permite a las empresas mexicanas:
- **Backend:** Node.js + Express + TypeScript
- **Base de datos:** PostgreSQL (multi-tenant por schema)
- **Autenticación:** JWT personalizado
- **Estado:** Zustand con persistencia
## Estructura del Proyecto
@@ -50,9 +51,32 @@ horux360/
## Características Destacadas
- **4 Temas visuales:** Light, Vibrant, Corporate, Dark
- **Multi-tenant:** Aislamiento de datos por empresa
- **Multi-tenant:** Aislamiento de datos por empresa (schema por tenant)
- **Responsive:** Funciona en desktop y móvil
- **Tiempo real:** Dashboards actualizados al instante
- **Carga masiva de XML:** Soporte para carga de hasta 300MB de archivos XML
- **Selector de período:** Navegación por mes/año en todos los dashboards
- **Clasificación automática:** Ingresos/egresos basado en RFC del tenant
## Configuración
### Variables de entorno (API)
```env
NODE_ENV=development
PORT=4000
DATABASE_URL="postgresql://user:pass@localhost:5432/horux360"
JWT_SECRET=your-secret-key
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
CORS_ORIGIN=http://localhost:3000
```
### Variables de entorno (Web)
```env
NEXT_PUBLIC_API_URL=http://localhost:4000/api
```
## Demo
@@ -61,6 +85,33 @@ Credenciales de demo:
- **Contador:** contador@demo.com / demo123
- **Visor:** visor@demo.com / demo123
## Changelog
### v0.4.0 (2026-01-22)
- Carga masiva de XML CFDI (hasta 300MB)
- Selector de período mes/año en dashboards
- Fix: Persistencia de sesión en refresh de página
- Fix: Clasificación ingreso/egreso basada en RFC
- Fix: Cálculo de IVA desde XML
- Mejoras en reportes con manejo de errores
- Soporte CORS para múltiples orígenes
### v0.3.0 (2026-01-22)
- Sistema multi-tenant con gestión de clientes
- Temas visuales (4 layouts diferentes)
### v0.2.0 (2026-01-22)
- Dashboard principal con KPIs
- Módulo de CFDI
- Control de IVA/ISR
- Calendario fiscal
- Sistema de alertas
### v0.1.0 (2026-01-22)
- Autenticación JWT
- Estructura multi-tenant
- Configuración inicial del proyecto
## Licencia
Propietario - Consultoría AS

View File

@@ -18,11 +18,12 @@
"@prisma/client": "^5.22.0",
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"dotenv": "^17.2.3",
"exceljs": "^4.4.0",
"express": "^4.21.0",
"helmet": "^8.0.0",
"jsonwebtoken": "^9.0.2",
"zod": "^3.23.0",
"exceljs": "^4.4.0"
"zod": "^3.23.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",

View File

@@ -1,7 +1,7 @@
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import { env } from './config/env.js';
import { env, getCorsOrigins } 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';
@@ -19,13 +19,13 @@ const app = express();
// Security
app.use(helmet());
app.use(cors({
origin: env.CORS_ORIGIN,
origin: getCorsOrigins(),
credentials: true,
}));
// Body parsing
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Body parsing - increased limit for bulk XML uploads
app.use(express.json({ limit: '300mb' }));
app.use(express.urlencoded({ extended: true, limit: '300mb' }));
// Health check
app.get('/health', (req, res) => {

View File

@@ -1,4 +1,9 @@
import { z } from 'zod';
import { config } from 'dotenv';
import { resolve } from 'path';
// Load .env file from the api package root
config({ path: resolve(process.cwd(), '.env') });
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
@@ -10,6 +15,11 @@ const envSchema = z.object({
CORS_ORIGIN: z.string().default('http://localhost:3000'),
});
// Parse CORS origins (comma-separated) into array
export function getCorsOrigins(): string[] {
return parsed.data.CORS_ORIGIN.split(',').map(origin => origin.trim());
}
const parsed = envSchema.safeParse(process.env);
if (!parsed.success) {

View File

@@ -60,3 +60,70 @@ export async function getResumen(req: Request, res: Response, next: NextFunction
next(error);
}
}
export async function createCfdi(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
}
// Only admin and contador can create CFDIs
if (!['admin', 'contador'].includes(req.user!.role)) {
return next(new AppError(403, 'No tienes permisos para agregar CFDIs'));
}
const cfdi = await cfdiService.createCfdi(req.tenantSchema, req.body);
res.status(201).json(cfdi);
} catch (error: any) {
if (error.message?.includes('duplicate')) {
return next(new AppError(409, 'Este CFDI ya existe (UUID duplicado)'));
}
next(error);
}
}
export async function createManyCfdis(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
}
if (!['admin', 'contador'].includes(req.user!.role)) {
return next(new AppError(403, 'No tienes permisos para agregar CFDIs'));
}
if (!Array.isArray(req.body.cfdis)) {
return next(new AppError(400, 'Se requiere un array de CFDIs'));
}
console.log(`[CFDI Bulk] Recibidos ${req.body.cfdis.length} CFDIs para schema ${req.tenantSchema}`);
// Log first CFDI for debugging
if (req.body.cfdis.length > 0) {
console.log('[CFDI Bulk] Primer CFDI:', JSON.stringify(req.body.cfdis[0], null, 2));
}
const count = await cfdiService.createManyCfdis(req.tenantSchema, req.body.cfdis);
res.status(201).json({ message: `${count} CFDIs creados exitosamente`, count });
} catch (error: any) {
console.error('[CFDI Bulk Error]', error.message, error.stack);
next(new AppError(400, error.message || 'Error al procesar CFDIs'));
}
}
export async function deleteCfdi(req: Request, res: Response, next: NextFunction) {
try {
if (!req.tenantSchema) {
return next(new AppError(400, 'Schema no configurado'));
}
if (!['admin', 'contador'].includes(req.user!.role)) {
return next(new AppError(403, 'No tienes permisos para eliminar CFDIs'));
}
await cfdiService.deleteCfdi(req.tenantSchema, req.params.id);
res.status(204).send();
} catch (error) {
next(error);
}
}

View File

@@ -8,9 +8,11 @@ export async function getEstadoResultados(req: Request, res: Response, next: Nex
const inicio = (fechaInicio as string) || `${now.getFullYear()}-01-01`;
const fin = (fechaFin as string) || now.toISOString().split('T')[0];
console.log('[reportes] getEstadoResultados - schema:', req.tenantSchema, 'inicio:', inicio, 'fin:', fin);
const data = await reportesService.getEstadoResultados(req.tenantSchema!, inicio, fin);
res.json(data);
} catch (error) {
console.error('[reportes] Error en getEstadoResultados:', error);
next(error);
}
}

View File

@@ -58,3 +58,40 @@ export async function createTenant(req: Request, res: Response, next: NextFuncti
next(error);
}
}
export async function updateTenant(req: Request, res: Response, next: NextFunction) {
try {
if (req.user!.role !== 'admin') {
throw new AppError(403, 'Solo administradores pueden editar clientes');
}
const { id } = req.params;
const { nombre, rfc, plan, cfdiLimit, usersLimit, active } = req.body;
const tenant = await tenantsService.updateTenant(id, {
nombre,
rfc,
plan,
cfdiLimit,
usersLimit,
active,
});
res.json(tenant);
} catch (error) {
next(error);
}
}
export async function deleteTenant(req: Request, res: Response, next: NextFunction) {
try {
if (req.user!.role !== 'admin') {
throw new AppError(403, 'Solo administradores pueden eliminar clientes');
}
await tenantsService.deleteTenant(req.params.id);
res.status(204).send();
} catch (error) {
next(error);
}
}

View File

@@ -11,5 +11,8 @@ router.use(tenantMiddleware);
router.get('/', cfdiController.getCfdis);
router.get('/resumen', cfdiController.getResumen);
router.get('/:id', cfdiController.getCfdiById);
router.post('/', cfdiController.createCfdi);
router.post('/bulk', cfdiController.createManyCfdis);
router.delete('/:id', cfdiController.deleteCfdi);
export { router as cfdiRoutes };

View File

@@ -9,5 +9,7 @@ router.use(authenticate);
router.get('/', tenantsController.getAllTenants);
router.get('/:id', tenantsController.getTenant);
router.post('/', tenantsController.createTenant);
router.put('/:id', tenantsController.updateTenant);
router.delete('/:id', tenantsController.deleteTenant);
export { router as tenantsRoutes };

View File

@@ -78,6 +78,7 @@ export async function register(data: RegisterRequest): Promise<LoginResponse> {
role: user.role,
tenantId: tenant.id,
tenantName: tenant.nombre,
tenantRfc: tenant.rfc,
},
};
}
@@ -140,6 +141,7 @@ export async function login(data: LoginRequest): Promise<LoginResponse> {
role: user.role,
tenantId: user.tenantId,
tenantName: user.tenant.nombre,
tenantRfc: user.tenant.rfc,
},
};
}

View File

@@ -94,6 +94,147 @@ export async function getCfdiById(schema: string, id: string): Promise<Cfdi | nu
return result[0] || null;
}
export interface CreateCfdiData {
uuidFiscal: string;
tipo: 'ingreso' | 'egreso' | 'traslado' | 'nomina' | 'pago';
serie?: string;
folio?: string;
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;
formaPago?: string;
usoCfdi?: string;
estado?: string;
xmlUrl?: string;
pdfUrl?: string;
}
export async function createCfdi(schema: string, data: CreateCfdiData): Promise<Cfdi> {
// Validate required fields
if (!data.uuidFiscal) throw new Error('UUID Fiscal es requerido');
if (!data.fechaEmision) throw new Error('Fecha de emisión es requerida');
if (!data.rfcEmisor) throw new Error('RFC Emisor es requerido');
if (!data.rfcReceptor) throw new Error('RFC Receptor es requerido');
// Parse dates safely - handle YYYY-MM-DD format explicitly
let fechaEmision: Date;
let fechaTimbrado: Date;
// If date is in YYYY-MM-DD format, add time to avoid timezone issues
const dateStr = typeof data.fechaEmision === 'string' && data.fechaEmision.match(/^\d{4}-\d{2}-\d{2}$/)
? `${data.fechaEmision}T12:00:00`
: data.fechaEmision;
fechaEmision = new Date(dateStr);
if (isNaN(fechaEmision.getTime())) {
throw new Error(`Fecha de emisión inválida: ${data.fechaEmision}`);
}
const timbradoStr = data.fechaTimbrado
? (typeof data.fechaTimbrado === 'string' && data.fechaTimbrado.match(/^\d{4}-\d{2}-\d{2}$/)
? `${data.fechaTimbrado}T12:00:00`
: data.fechaTimbrado)
: null;
fechaTimbrado = timbradoStr ? new Date(timbradoStr) : fechaEmision;
if (isNaN(fechaTimbrado.getTime())) {
throw new Error(`Fecha de timbrado inválida: ${data.fechaTimbrado}`);
}
const result = await prisma.$queryRawUnsafe<Cfdi[]>(`
INSERT INTO "${schema}".cfdis (
uuid_fiscal, tipo, serie, folio, fecha_emision, fecha_timbrado,
rfc_emisor, nombre_emisor, rfc_receptor, nombre_receptor,
subtotal, descuento, iva, isr_retenido, iva_retenido, total,
moneda, tipo_cambio, metodo_pago, forma_pago, uso_cfdi, estado, xml_url, pdf_url
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)
RETURNING
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"
`,
data.uuidFiscal,
data.tipo || 'ingreso',
data.serie || null,
data.folio || null,
fechaEmision,
fechaTimbrado,
data.rfcEmisor,
data.nombreEmisor || 'Sin nombre',
data.rfcReceptor,
data.nombreReceptor || 'Sin nombre',
data.subtotal || 0,
data.descuento || 0,
data.iva || 0,
data.isrRetenido || 0,
data.ivaRetenido || 0,
data.total || 0,
data.moneda || 'MXN',
data.tipoCambio || 1,
data.metodoPago || null,
data.formaPago || null,
data.usoCfdi || null,
data.estado || 'vigente',
data.xmlUrl || null,
data.pdfUrl || null
);
return result[0];
}
export async function createManyCfdis(schema: string, cfdis: CreateCfdiData[]): Promise<number> {
let count = 0;
const errors: string[] = [];
for (let i = 0; i < cfdis.length; i++) {
const cfdi = cfdis[i];
try {
await createCfdi(schema, cfdi);
count++;
} catch (error: any) {
const errorMsg = error.message || 'Error desconocido';
// Skip duplicates (uuid_fiscal is unique)
if (errorMsg.includes('duplicate') || errorMsg.includes('unique')) {
console.log(`[CFDI ${i + 1}] Duplicado: ${cfdi.uuidFiscal}`);
continue;
}
console.error(`[CFDI ${i + 1}] Error: ${errorMsg}`, { uuid: cfdi.uuidFiscal });
errors.push(`CFDI ${i + 1} (${cfdi.uuidFiscal?.substring(0, 8) || 'sin UUID'}): ${errorMsg}`);
}
}
if (errors.length > 0 && count === 0) {
throw new Error(`No se pudo crear ningun CFDI. Errores: ${errors.slice(0, 3).join('; ')}`);
}
return count;
}
export async function deleteCfdi(schema: string, id: string): Promise<void> {
await prisma.$queryRawUnsafe(`DELETE FROM "${schema}".cfdis WHERE id = $1`, id);
}
export async function getResumenCfdis(schema: string, año: number, mes: number) {
const result = await prisma.$queryRawUnsafe<[{
total_ingresos: number;

View File

@@ -1,48 +1,61 @@
import { prisma } from '../config/database.js';
import type { EstadoResultados, FlujoEfectivo, ComparativoPeriodos, ConcentradoRfc } from '@horux/shared';
// Helper to convert Prisma Decimal/BigInt to number
function toNumber(value: unknown): number {
if (value === null || value === undefined) return 0;
if (typeof value === 'number') return value;
if (typeof value === 'bigint') return Number(value);
if (typeof value === 'string') return parseFloat(value) || 0;
if (typeof value === 'object' && value !== null && 'toNumber' in value) {
return (value as { toNumber: () => number }).toNumber();
}
return Number(value) || 0;
}
export async function getEstadoResultados(
schema: string,
fechaInicio: string,
fechaFin: string
): Promise<EstadoResultados> {
const ingresos = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; total: number }[]>(`
const ingresos = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; total: unknown }[]>(`
SELECT rfc_receptor as rfc, nombre_receptor as nombre, SUM(subtotal) as total
FROM "${schema}".cfdis
WHERE tipo = 'ingreso' AND estado = 'vigente'
AND fecha_emision BETWEEN $1 AND $2
AND fecha_emision BETWEEN $1::date AND $2::date
GROUP BY rfc_receptor, nombre_receptor
ORDER BY total DESC LIMIT 10
`, fechaInicio, fechaFin);
const egresos = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; total: number }[]>(`
const egresos = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; total: unknown }[]>(`
SELECT rfc_emisor as rfc, nombre_emisor as nombre, SUM(subtotal) as total
FROM "${schema}".cfdis
WHERE tipo = 'egreso' AND estado = 'vigente'
AND fecha_emision BETWEEN $1 AND $2
AND fecha_emision BETWEEN $1::date AND $2::date
GROUP BY rfc_emisor, nombre_emisor
ORDER BY total DESC LIMIT 10
`, fechaInicio, fechaFin);
const [totales] = await prisma.$queryRawUnsafe<[{ ingresos: number; egresos: number; iva: number }]>(`
const totalesResult = await prisma.$queryRawUnsafe<{ ingresos: unknown; egresos: unknown; iva: unknown }[]>(`
SELECT
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN subtotal ELSE 0 END), 0) as ingresos,
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN subtotal ELSE 0 END), 0) as egresos,
COALESCE(SUM(CASE WHEN tipo = 'ingreso' THEN iva ELSE 0 END), 0) -
COALESCE(SUM(CASE WHEN tipo = 'egreso' THEN iva ELSE 0 END), 0) as iva
FROM "${schema}".cfdis
WHERE estado = 'vigente' AND fecha_emision BETWEEN $1 AND $2
WHERE estado = 'vigente' AND fecha_emision BETWEEN $1::date AND $2::date
`, fechaInicio, fechaFin);
const totalIngresos = Number(totales?.ingresos || 0);
const totalEgresos = Number(totales?.egresos || 0);
const totales = totalesResult[0];
const totalIngresos = toNumber(totales?.ingresos);
const totalEgresos = toNumber(totales?.egresos);
const utilidadBruta = totalIngresos - totalEgresos;
const impuestos = Number(totales?.iva || 0);
const impuestos = toNumber(totales?.iva);
return {
periodo: { inicio: fechaInicio, fin: fechaFin },
ingresos: ingresos.map(i => ({ concepto: i.nombre, monto: Number(i.total) })),
egresos: egresos.map(e => ({ concepto: e.nombre, monto: Number(e.total) })),
ingresos: ingresos.map(i => ({ concepto: i.nombre, monto: toNumber(i.total) })),
egresos: egresos.map(e => ({ concepto: e.nombre, monto: toNumber(e.total) })),
totalIngresos,
totalEgresos,
utilidadBruta,
@@ -56,32 +69,32 @@ export async function getFlujoEfectivo(
fechaInicio: string,
fechaFin: string
): Promise<FlujoEfectivo> {
const entradas = await prisma.$queryRawUnsafe<{ mes: string; total: number }[]>(`
const entradas = await prisma.$queryRawUnsafe<{ mes: string; total: unknown }[]>(`
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, SUM(total) as total
FROM "${schema}".cfdis
WHERE tipo = 'ingreso' AND estado = 'vigente'
AND fecha_emision BETWEEN $1 AND $2
AND fecha_emision BETWEEN $1::date AND $2::date
GROUP BY TO_CHAR(fecha_emision, 'YYYY-MM')
ORDER BY mes
`, fechaInicio, fechaFin);
const salidas = await prisma.$queryRawUnsafe<{ mes: string; total: number }[]>(`
const salidas = await prisma.$queryRawUnsafe<{ mes: string; total: unknown }[]>(`
SELECT TO_CHAR(fecha_emision, 'YYYY-MM') as mes, SUM(total) as total
FROM "${schema}".cfdis
WHERE tipo = 'egreso' AND estado = 'vigente'
AND fecha_emision BETWEEN $1 AND $2
AND fecha_emision BETWEEN $1::date AND $2::date
GROUP BY TO_CHAR(fecha_emision, 'YYYY-MM')
ORDER BY mes
`, fechaInicio, fechaFin);
const totalEntradas = entradas.reduce((sum, e) => sum + Number(e.total), 0);
const totalSalidas = salidas.reduce((sum, s) => sum + Number(s.total), 0);
const totalEntradas = entradas.reduce((sum, e) => sum + toNumber(e.total), 0);
const totalSalidas = salidas.reduce((sum, s) => sum + toNumber(s.total), 0);
return {
periodo: { inicio: fechaInicio, fin: fechaFin },
saldoInicial: 0,
entradas: entradas.map(e => ({ concepto: e.mes, monto: Number(e.total) })),
salidas: salidas.map(s => ({ concepto: s.mes, monto: Number(s.total) })),
entradas: entradas.map(e => ({ concepto: e.mes, monto: toNumber(e.total) })),
salidas: salidas.map(s => ({ concepto: s.mes, monto: toNumber(s.total) })),
totalEntradas,
totalSalidas,
flujoNeto: totalEntradas - totalSalidas,
@@ -93,7 +106,7 @@ export async function getComparativo(
schema: string,
año: number
): Promise<ComparativoPeriodos> {
const actual = await prisma.$queryRawUnsafe<{ mes: number; ingresos: number; egresos: number }[]>(`
const actual = await prisma.$queryRawUnsafe<{ mes: number; ingresos: unknown; egresos: unknown }[]>(`
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
@@ -102,7 +115,7 @@ export async function getComparativo(
GROUP BY mes ORDER BY mes
`, año);
const anterior = await prisma.$queryRawUnsafe<{ mes: number; ingresos: number; egresos: number }[]>(`
const anterior = await prisma.$queryRawUnsafe<{ mes: number; ingresos: unknown; egresos: unknown }[]>(`
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
@@ -112,14 +125,14 @@ export async function getComparativo(
`, año - 1);
const meses = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
const ingresos = meses.map((_, i) => Number(actual.find(a => a.mes === i + 1)?.ingresos || 0));
const egresos = meses.map((_, i) => Number(actual.find(a => a.mes === i + 1)?.egresos || 0));
const ingresos = meses.map((_, i) => toNumber(actual.find(a => a.mes === i + 1)?.ingresos));
const egresos = meses.map((_, i) => toNumber(actual.find(a => a.mes === i + 1)?.egresos));
const utilidad = ingresos.map((ing, i) => ing - egresos[i]);
const totalActualIng = ingresos.reduce((a, b) => a + b, 0);
const totalAnteriorIng = anterior.reduce((a, b) => a + Number(b.ingresos), 0);
const totalAnteriorIng = anterior.reduce((a, b) => a + toNumber(b.ingresos), 0);
const totalActualEgr = egresos.reduce((a, b) => a + b, 0);
const totalAnteriorEgr = anterior.reduce((a, b) => a + Number(b.egresos), 0);
const totalAnteriorEgr = anterior.reduce((a, b) => a + toNumber(b.egresos), 0);
return {
periodos: meses,
@@ -139,7 +152,7 @@ export async function getConcentradoRfc(
tipo: 'cliente' | 'proveedor'
): Promise<ConcentradoRfc[]> {
if (tipo === 'cliente') {
const data = await prisma.$queryRawUnsafe<ConcentradoRfc[]>(`
const data = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; tipo: string; totalFacturado: unknown; totalIva: unknown; cantidadCfdis: number }[]>(`
SELECT rfc_receptor as rfc, nombre_receptor as nombre,
'cliente' as tipo,
SUM(total) as "totalFacturado",
@@ -147,13 +160,20 @@ export async function getConcentradoRfc(
COUNT(*)::int as "cantidadCfdis"
FROM "${schema}".cfdis
WHERE tipo = 'ingreso' AND estado = 'vigente'
AND fecha_emision BETWEEN $1 AND $2
AND fecha_emision BETWEEN $1::date AND $2::date
GROUP BY rfc_receptor, nombre_receptor
ORDER BY "totalFacturado" DESC
`, fechaInicio, fechaFin);
return data.map(d => ({ ...d, totalFacturado: Number(d.totalFacturado), totalIva: Number(d.totalIva) }));
return data.map(d => ({
rfc: d.rfc,
nombre: d.nombre,
tipo: 'cliente' as const,
totalFacturado: toNumber(d.totalFacturado),
totalIva: toNumber(d.totalIva),
cantidadCfdis: d.cantidadCfdis
}));
} else {
const data = await prisma.$queryRawUnsafe<ConcentradoRfc[]>(`
const data = await prisma.$queryRawUnsafe<{ rfc: string; nombre: string; tipo: string; totalFacturado: unknown; totalIva: unknown; cantidadCfdis: number }[]>(`
SELECT rfc_emisor as rfc, nombre_emisor as nombre,
'proveedor' as tipo,
SUM(total) as "totalFacturado",
@@ -161,10 +181,17 @@ export async function getConcentradoRfc(
COUNT(*)::int as "cantidadCfdis"
FROM "${schema}".cfdis
WHERE tipo = 'egreso' AND estado = 'vigente'
AND fecha_emision BETWEEN $1 AND $2
AND fecha_emision BETWEEN $1::date AND $2::date
GROUP BY rfc_emisor, nombre_emisor
ORDER BY "totalFacturado" DESC
`, fechaInicio, fechaFin);
return data.map(d => ({ ...d, totalFacturado: Number(d.totalFacturado), totalIva: Number(d.totalIva) }));
return data.map(d => ({
rfc: d.rfc,
nombre: d.nombre,
tipo: 'proveedor' as const,
totalFacturado: toNumber(d.totalFacturado),
totalIva: toNumber(d.totalIva),
cantidadCfdis: d.cantidadCfdis
}));
}
}

View File

@@ -139,3 +139,43 @@ export async function createTenant(data: {
return tenant;
}
export async function updateTenant(id: string, data: {
nombre?: string;
rfc?: string;
plan?: 'starter' | 'business' | 'professional' | 'enterprise';
cfdiLimit?: number;
usersLimit?: number;
active?: boolean;
}) {
return prisma.tenant.update({
where: { id },
data: {
...(data.nombre && { nombre: data.nombre }),
...(data.rfc && { rfc: data.rfc.toUpperCase() }),
...(data.plan && { plan: data.plan }),
...(data.cfdiLimit !== undefined && { cfdiLimit: data.cfdiLimit }),
...(data.usersLimit !== undefined && { usersLimit: data.usersLimit }),
...(data.active !== undefined && { active: data.active }),
},
select: {
id: true,
nombre: true,
rfc: true,
plan: true,
schemaName: true,
cfdiLimit: true,
usersLimit: true,
active: true,
createdAt: true,
}
});
}
export async function deleteTenant(id: string) {
// Soft delete - just mark as inactive
return prisma.tenant.update({
where: { id },
data: { active: false }
});
}

View File

@@ -45,10 +45,7 @@ export default function AlertasPage() {
};
return (
<DashboardShell
title="Alertas"
description="Gestiona tus alertas y notificaciones"
>
<DashboardShell title="Alertas">
<div className="space-y-4">
{/* Stats */}
<div className="grid gap-4 md:grid-cols-4">

View File

@@ -69,10 +69,7 @@ export default function CalendarioPage() {
};
return (
<DashboardShell
title="Calendario Fiscal"
description="Obligaciones fiscales y eventos importantes"
>
<DashboardShell title="Calendario Fiscal">
<div className="grid gap-4 lg:grid-cols-3">
{/* Calendar */}
<Card className="lg:col-span-2">

View File

@@ -1,22 +1,226 @@
'use client';
import { useState } from 'react';
import { useState, useRef } from 'react';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useCfdis } from '@/lib/hooks/use-cfdi';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useCfdis, useCreateCfdi, useCreateManyCfdis, useDeleteCfdi } from '@/lib/hooks/use-cfdi';
import type { CfdiFilters, TipoCfdi } from '@horux/shared';
import { FileText, Search, ChevronLeft, ChevronRight } from 'lucide-react';
import type { CreateCfdiData } from '@/lib/api/cfdi';
import { FileText, Search, ChevronLeft, ChevronRight, Plus, Upload, Trash2, X, FileUp, CheckCircle, AlertCircle } from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store';
import { useTenantViewStore } from '@/stores/tenant-view-store';
type CfdiTipo = 'ingreso' | 'egreso' | 'traslado' | 'nomina' | 'pago';
const initialFormData: CreateCfdiData = {
uuidFiscal: '',
tipo: 'ingreso',
serie: '',
folio: '',
fechaEmision: new Date().toISOString().split('T')[0],
fechaTimbrado: new Date().toISOString().split('T')[0],
rfcEmisor: '',
nombreEmisor: '',
rfcReceptor: '',
nombreReceptor: '',
subtotal: 0,
descuento: 0,
iva: 0,
isrRetenido: 0,
ivaRetenido: 0,
total: 0,
moneda: 'MXN',
metodoPago: 'PUE',
formaPago: '03',
usoCfdi: 'G03',
};
// Helper function to find element regardless of namespace prefix
function findElement(doc: Document, localName: string): Element | null {
// Try common prefixes first (most reliable for CFDI)
const prefixes = ['cfdi', 'tfd', 'pago20', 'pago10', 'nomina12', ''];
for (const prefix of prefixes) {
const tagName = prefix ? `${prefix}:${localName}` : localName;
const el = doc.getElementsByTagName(tagName)[0] as Element;
if (el) return el;
}
// Try with wildcard - search all elements by localName
const elements = doc.getElementsByTagName('*');
for (let i = 0; i < elements.length; i++) {
if (elements[i].localName === localName) {
return elements[i];
}
}
return null;
}
// Parse CFDI XML and extract data
function parseCfdiXml(xmlString: string, tenantRfc: string): CreateCfdiData | null {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(xmlString, 'text/xml');
// Check for parse errors
const parseError = doc.querySelector('parsererror');
if (parseError) {
console.error('XML parse error:', parseError.textContent);
return null;
}
// Get the Comprobante element (root)
const comprobante = findElement(doc, 'Comprobante');
if (!comprobante) {
console.error('No se encontro elemento Comprobante');
return null;
}
// Get TimbreFiscalDigital for UUID
const timbre = findElement(doc, 'TimbreFiscalDigital');
const uuid = timbre?.getAttribute('UUID') || '';
const fechaTimbradoRaw = timbre?.getAttribute('FechaTimbrado') || '';
// Get Emisor
const emisor = findElement(doc, 'Emisor');
const rfcEmisor = emisor?.getAttribute('Rfc') || emisor?.getAttribute('rfc') || '';
const nombreEmisor = emisor?.getAttribute('Nombre') || emisor?.getAttribute('nombre') || '';
// Get Receptor
const receptor = findElement(doc, 'Receptor');
const rfcReceptor = receptor?.getAttribute('Rfc') || receptor?.getAttribute('rfc') || '';
const nombreReceptor = receptor?.getAttribute('Nombre') || receptor?.getAttribute('nombre') || '';
const usoCfdi = receptor?.getAttribute('UsoCFDI') || '';
// Determine type based on tenant RFC
// If tenant is emisor -> ingreso (we issued the invoice)
// If tenant is receptor -> egreso (we received the invoice)
const tenantRfcUpper = tenantRfc.toUpperCase();
let tipoFinal: CreateCfdiData['tipo'];
if (rfcEmisor.toUpperCase() === tenantRfcUpper) {
tipoFinal = 'ingreso';
} else if (rfcReceptor.toUpperCase() === tenantRfcUpper) {
tipoFinal = 'egreso';
} else {
// Fallback: use TipoDeComprobante
const tipoComprobante = comprobante.getAttribute('TipoDeComprobante') || 'I';
tipoFinal = tipoComprobante === 'E' ? 'egreso' : 'ingreso';
}
// Get impuestos - search for the Impuestos element that is direct child of Comprobante
// (not the ones inside Conceptos)
let totalImpuestosTrasladados = 0;
let totalImpuestosRetenidos = 0;
// Try to get TotalImpuestosTrasladados from Comprobante's direct Impuestos child
const allImpuestos = doc.getElementsByTagName('*');
for (let i = 0; i < allImpuestos.length; i++) {
const el = allImpuestos[i];
if (el.localName === 'Impuestos' && el.parentElement?.localName === 'Comprobante') {
totalImpuestosTrasladados = parseFloat(el.getAttribute('TotalImpuestosTrasladados') || '0');
totalImpuestosRetenidos = parseFloat(el.getAttribute('TotalImpuestosRetenidos') || '0');
break;
}
}
// Fallback: calculate IVA from total - subtotal if not found
const subtotal = parseFloat(comprobante.getAttribute('SubTotal') || '0');
const descuento = parseFloat(comprobante.getAttribute('Descuento') || '0');
const total = parseFloat(comprobante.getAttribute('Total') || '0');
if (totalImpuestosTrasladados === 0 && total > subtotal) {
totalImpuestosTrasladados = Math.max(0, total - subtotal + descuento + totalImpuestosRetenidos);
}
// Get retenciones breakdown
let isrRetenido = 0;
let ivaRetenido = 0;
const retenciones = doc.querySelectorAll('[localName="Retencion"], Retencion, cfdi\\:Retencion');
retenciones.forEach((ret: Element) => {
const impuesto = ret.getAttribute('Impuesto');
const importe = parseFloat(ret.getAttribute('Importe') || '0');
if (impuesto === '001') isrRetenido = importe; // ISR
if (impuesto === '002') ivaRetenido = importe; // IVA
});
// Parse dates - handle both ISO format and datetime format
const fechaEmisionRaw = comprobante.getAttribute('Fecha') || '';
const fechaEmision = fechaEmisionRaw.includes('T') ? fechaEmisionRaw.split('T')[0] : fechaEmisionRaw;
const fechaTimbrado = fechaTimbradoRaw.includes('T') ? fechaTimbradoRaw.split('T')[0] : fechaTimbradoRaw;
// Validate required fields
if (!uuid) {
console.error('UUID no encontrado en el XML');
return null;
}
if (!rfcEmisor || !rfcReceptor) {
console.error('RFC emisor o receptor no encontrado');
return null;
}
if (!fechaEmision) {
console.error('Fecha de emision no encontrada');
return null;
}
return {
uuidFiscal: uuid.toUpperCase(),
tipo: tipoFinal,
serie: comprobante.getAttribute('Serie') || '',
folio: comprobante.getAttribute('Folio') || '',
fechaEmision,
fechaTimbrado: fechaTimbrado || fechaEmision,
rfcEmisor,
nombreEmisor: nombreEmisor || 'Sin nombre',
rfcReceptor,
nombreReceptor: nombreReceptor || 'Sin nombre',
subtotal,
descuento,
iva: totalImpuestosTrasladados,
isrRetenido,
ivaRetenido,
total,
moneda: comprobante.getAttribute('Moneda') || 'MXN',
tipoCambio: parseFloat(comprobante.getAttribute('TipoCambio') || '1'),
metodoPago: comprobante.getAttribute('MetodoPago') || '',
formaPago: comprobante.getAttribute('FormaPago') || '',
usoCfdi,
};
} catch (error) {
console.error('Error parsing XML:', error);
return null;
}
}
export default function CfdiPage() {
const { user } = useAuthStore();
const { viewingTenantRfc } = useTenantViewStore();
const fileInputRef = useRef<HTMLInputElement>(null);
// Get the effective tenant RFC (viewing tenant or user's tenant)
const tenantRfc = viewingTenantRfc || user?.tenantRfc || '';
const [filters, setFilters] = useState<CfdiFilters>({
page: 1,
limit: 20,
});
const [searchTerm, setSearchTerm] = useState('');
const [showForm, setShowForm] = useState(false);
const [showBulkForm, setShowBulkForm] = useState(false);
const [formData, setFormData] = useState<CreateCfdiData>(initialFormData);
const [bulkData, setBulkData] = useState('');
const [xmlFiles, setXmlFiles] = useState<File[]>([]);
const [parsedXmls, setParsedXmls] = useState<{ file: string; data: CreateCfdiData | null; error?: string }[]>([]);
const [uploadMode, setUploadMode] = useState<'xml' | 'json'>('xml');
const { data, isLoading } = useCfdis(filters);
const createCfdi = useCreateCfdi();
const createManyCfdis = useCreateManyCfdis();
const deleteCfdi = useDeleteCfdi();
const canEdit = user?.role === 'admin' || user?.role === 'contador';
const handleSearch = () => {
setFilters({ ...filters, search: searchTerm, page: 1 });
@@ -26,6 +230,112 @@ export default function CfdiPage() {
setFilters({ ...filters, tipo, page: 1 });
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await createCfdi.mutateAsync(formData);
setFormData(initialFormData);
setShowForm(false);
} catch (error: any) {
alert(error.response?.data?.message || 'Error al crear CFDI');
}
};
const handleBulkSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const cfdis = JSON.parse(bulkData);
if (!Array.isArray(cfdis)) {
throw new Error('El formato debe ser un array de CFDIs');
}
const result = await createManyCfdis.mutateAsync(cfdis);
alert(`Se crearon ${result.count} CFDIs exitosamente`);
setBulkData('');
setShowBulkForm(false);
} catch (error: any) {
alert(error.message || 'Error al procesar CFDIs');
}
};
const handleXmlFilesChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
setXmlFiles(files);
// Parse each XML file
const parsed = await Promise.all(
files.map(async (file) => {
try {
const text = await file.text();
const data = parseCfdiXml(text, tenantRfc);
if (!data) {
return { file: file.name, data: null, error: 'No se pudo parsear el XML' };
}
if (!data.uuidFiscal) {
return { file: file.name, data: null, error: 'UUID no encontrado en el XML' };
}
return { file: file.name, data };
} catch (error) {
return { file: file.name, data: null, error: 'Error al leer el archivo' };
}
})
);
setParsedXmls(parsed);
};
const handleXmlBulkSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const validCfdis = parsedXmls
.filter((p) => p.data !== null)
.map((p) => p.data as CreateCfdiData);
if (validCfdis.length === 0) {
alert('No hay CFDIs validos para cargar');
return;
}
try {
const result = await createManyCfdis.mutateAsync(validCfdis);
alert(`Se crearon ${result.count} CFDIs exitosamente`);
setXmlFiles([]);
setParsedXmls([]);
setShowBulkForm(false);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
} catch (error: any) {
alert(error.response?.data?.message || 'Error al cargar CFDIs');
}
};
const clearXmlFiles = () => {
setXmlFiles([]);
setParsedXmls([]);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleDelete = async (id: string) => {
if (confirm('¿Eliminar este CFDI?')) {
try {
await deleteCfdi.mutateAsync(id);
} catch (error) {
console.error('Error deleting CFDI:', error);
}
}
};
const calculateTotal = () => {
const subtotal = formData.subtotal || 0;
const descuento = formData.descuento || 0;
const iva = formData.iva || 0;
const isrRetenido = formData.isrRetenido || 0;
const ivaRetenido = formData.ivaRetenido || 0;
return subtotal - descuento + iva - isrRetenido - ivaRetenido;
};
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', {
style: 'currency',
@@ -39,6 +349,14 @@ export default function CfdiPage() {
year: 'numeric',
});
const generateUUID = () => {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
}).toUpperCase();
};
return (
<>
<Header title="Gestion de CFDI" />
@@ -81,10 +399,378 @@ export default function CfdiPage() {
Egresos
</Button>
</div>
{canEdit && (
<div className="flex gap-2">
<Button onClick={() => { setShowForm(true); setShowBulkForm(false); }}>
<Plus className="h-4 w-4 mr-1" />
Agregar
</Button>
<Button variant="outline" onClick={() => { setShowBulkForm(true); setShowForm(false); }}>
<Upload className="h-4 w-4 mr-1" />
Carga Masiva
</Button>
</div>
)}
</div>
</CardContent>
</Card>
{/* Add CFDI Form */}
{showForm && canEdit && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Agregar CFDI</CardTitle>
<CardDescription>Ingresa los datos del comprobante fiscal</CardDescription>
</div>
<Button variant="ghost" size="icon" onClick={() => setShowForm(false)}>
<X className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
<div className="space-y-2">
<Label>UUID Fiscal</Label>
<div className="flex gap-2">
<Input
value={formData.uuidFiscal}
onChange={(e) => setFormData({ ...formData, uuidFiscal: e.target.value.toUpperCase() })}
placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
required
/>
<Button type="button" variant="outline" onClick={() => setFormData({ ...formData, uuidFiscal: generateUUID() })}>
Gen
</Button>
</div>
</div>
<div className="space-y-2">
<Label>Tipo</Label>
<Select
value={formData.tipo}
onValueChange={(v) => setFormData({ ...formData, tipo: v as CfdiTipo })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ingreso">Ingreso</SelectItem>
<SelectItem value="egreso">Egreso</SelectItem>
<SelectItem value="traslado">Traslado</SelectItem>
<SelectItem value="nomina">Nomina</SelectItem>
<SelectItem value="pago">Pago</SelectItem>
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-2">
<Label>Serie</Label>
<Input
value={formData.serie}
onChange={(e) => setFormData({ ...formData, serie: e.target.value })}
placeholder="A"
/>
</div>
<div className="space-y-2">
<Label>Folio</Label>
<Input
value={formData.folio}
onChange={(e) => setFormData({ ...formData, folio: e.target.value })}
placeholder="001"
/>
</div>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label>Fecha Emision</Label>
<Input
type="date"
value={formData.fechaEmision}
onChange={(e) => setFormData({ ...formData, fechaEmision: e.target.value, fechaTimbrado: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label>Fecha Timbrado</Label>
<Input
type="date"
value={formData.fechaTimbrado}
onChange={(e) => setFormData({ ...formData, fechaTimbrado: e.target.value })}
required
/>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="p-4 border rounded-lg space-y-3">
<h4 className="font-medium">Emisor</h4>
<div className="space-y-2">
<Label>RFC Emisor</Label>
<Input
value={formData.rfcEmisor}
onChange={(e) => setFormData({ ...formData, rfcEmisor: e.target.value.toUpperCase() })}
placeholder="XAXX010101000"
maxLength={13}
required
/>
</div>
<div className="space-y-2">
<Label>Nombre Emisor</Label>
<Input
value={formData.nombreEmisor}
onChange={(e) => setFormData({ ...formData, nombreEmisor: e.target.value })}
placeholder="Empresa Emisora SA de CV"
required
/>
</div>
</div>
<div className="p-4 border rounded-lg space-y-3">
<h4 className="font-medium">Receptor</h4>
<div className="space-y-2">
<Label>RFC Receptor</Label>
<Input
value={formData.rfcReceptor}
onChange={(e) => setFormData({ ...formData, rfcReceptor: e.target.value.toUpperCase() })}
placeholder="XAXX010101000"
maxLength={13}
required
/>
</div>
<div className="space-y-2">
<Label>Nombre Receptor</Label>
<Input
value={formData.nombreReceptor}
onChange={(e) => setFormData({ ...formData, nombreReceptor: e.target.value })}
placeholder="Empresa Receptora SA de CV"
required
/>
</div>
</div>
</div>
<div className="grid gap-4 md:grid-cols-6">
<div className="space-y-2">
<Label>Subtotal</Label>
<Input
type="number"
step="0.01"
value={formData.subtotal}
onChange={(e) => setFormData({ ...formData, subtotal: parseFloat(e.target.value) || 0 })}
required
/>
</div>
<div className="space-y-2">
<Label>Descuento</Label>
<Input
type="number"
step="0.01"
value={formData.descuento}
onChange={(e) => setFormData({ ...formData, descuento: parseFloat(e.target.value) || 0 })}
/>
</div>
<div className="space-y-2">
<Label>IVA</Label>
<Input
type="number"
step="0.01"
value={formData.iva}
onChange={(e) => setFormData({ ...formData, iva: parseFloat(e.target.value) || 0 })}
/>
</div>
<div className="space-y-2">
<Label>ISR Ret.</Label>
<Input
type="number"
step="0.01"
value={formData.isrRetenido}
onChange={(e) => setFormData({ ...formData, isrRetenido: parseFloat(e.target.value) || 0 })}
/>
</div>
<div className="space-y-2">
<Label>IVA Ret.</Label>
<Input
type="number"
step="0.01"
value={formData.ivaRetenido}
onChange={(e) => setFormData({ ...formData, ivaRetenido: parseFloat(e.target.value) || 0 })}
/>
</div>
<div className="space-y-2">
<Label>Total</Label>
<Input
type="number"
step="0.01"
value={formData.total || calculateTotal()}
onChange={(e) => setFormData({ ...formData, total: parseFloat(e.target.value) || 0 })}
required
/>
</div>
</div>
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={() => setShowForm(false)}>
Cancelar
</Button>
<Button type="submit" disabled={createCfdi.isPending}>
{createCfdi.isPending ? 'Guardando...' : 'Guardar CFDI'}
</Button>
</div>
</form>
</CardContent>
</Card>
)}
{/* Bulk Upload Form */}
{showBulkForm && canEdit && (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Carga Masiva de CFDIs</CardTitle>
<CardDescription>Sube archivos XML o pega datos en formato JSON</CardDescription>
</div>
<Button variant="ghost" size="icon" onClick={() => { setShowBulkForm(false); clearXmlFiles(); }}>
<X className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
{/* Mode selector */}
<div className="flex gap-2 mb-4">
<Button
type="button"
variant={uploadMode === 'xml' ? 'default' : 'outline'}
size="sm"
onClick={() => setUploadMode('xml')}
>
<FileUp className="h-4 w-4 mr-1" />
Subir XMLs
</Button>
<Button
type="button"
variant={uploadMode === 'json' ? 'default' : 'outline'}
size="sm"
onClick={() => setUploadMode('json')}
>
JSON
</Button>
</div>
{uploadMode === 'xml' ? (
<form onSubmit={handleXmlBulkSubmit} className="space-y-4">
<div className="space-y-2">
<Label>Archivos XML de CFDI</Label>
<div className="border-2 border-dashed rounded-lg p-6 text-center">
<input
ref={fileInputRef}
type="file"
accept=".xml"
multiple
onChange={handleXmlFilesChange}
className="hidden"
id="xml-upload"
/>
<label htmlFor="xml-upload" className="cursor-pointer">
<FileUp className="h-10 w-10 mx-auto text-muted-foreground mb-2" />
<p className="text-sm text-muted-foreground">
Haz clic para seleccionar archivos XML o arrastralos aqui
</p>
<p className="text-xs text-muted-foreground mt-1">
Puedes seleccionar multiples archivos
</p>
</label>
</div>
</div>
{/* Show parsed results */}
{parsedXmls.length > 0 && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label>Archivos procesados ({parsedXmls.filter(p => p.data).length} validos de {parsedXmls.length})</Label>
<Button type="button" variant="ghost" size="sm" onClick={clearXmlFiles}>
Limpiar
</Button>
</div>
<div className="max-h-48 overflow-y-auto border rounded-lg divide-y">
{parsedXmls.map((parsed, idx) => (
<div key={idx} className="p-2 flex items-center gap-2 text-sm">
{parsed.data ? (
<CheckCircle className="h-4 w-4 text-success flex-shrink-0" />
) : (
<AlertCircle className="h-4 w-4 text-destructive flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{parsed.file}</p>
{parsed.data ? (
<p className="text-xs text-muted-foreground">
{parsed.data.tipo === 'ingreso' ? 'Ingreso' : 'Egreso'} - {parsed.data.nombreEmisor?.substring(0, 30)}... - ${parsed.data.total?.toLocaleString()}
</p>
) : (
<p className="text-xs text-destructive">{parsed.error}</p>
)}
</div>
</div>
))}
</div>
</div>
)}
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={() => { setShowBulkForm(false); clearXmlFiles(); }}>
Cancelar
</Button>
<Button
type="submit"
disabled={createManyCfdis.isPending || parsedXmls.filter(p => p.data).length === 0}
>
{createManyCfdis.isPending ? 'Procesando...' : `Cargar ${parsedXmls.filter(p => p.data).length} CFDIs`}
</Button>
</div>
</form>
) : (
<form onSubmit={handleBulkSubmit} className="space-y-4">
<div className="space-y-2">
<Label>Datos JSON</Label>
<textarea
className="w-full h-48 p-3 border rounded-lg font-mono text-sm bg-background"
value={bulkData}
onChange={(e) => setBulkData(e.target.value)}
placeholder={`[
{
"uuidFiscal": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX",
"tipo": "ingreso",
"fechaEmision": "2025-01-15",
"fechaTimbrado": "2025-01-15",
"rfcEmisor": "XAXX010101000",
"nombreEmisor": "Empresa SA de CV",
"rfcReceptor": "XAXX010101001",
"nombreReceptor": "Cliente SA de CV",
"subtotal": 10000,
"iva": 1600,
"total": 11600
}
]`}
required
/>
</div>
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={() => setShowBulkForm(false)}>
Cancelar
</Button>
<Button type="submit" disabled={createManyCfdis.isPending}>
{createManyCfdis.isPending ? 'Procesando...' : 'Cargar CFDIs'}
</Button>
</div>
</form>
)}
</CardContent>
</Card>
)}
{/* Table */}
<Card>
<CardHeader>
@@ -109,10 +795,12 @@ export default function CfdiPage() {
<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">Folio</th>
<th className="pb-3 font-medium">Emisor</th>
<th className="pb-3 font-medium">Receptor</th>
<th className="pb-3 font-medium text-right">Total</th>
<th className="pb-3 font-medium">Estado</th>
{canEdit && <th className="pb-3 font-medium"></th>}
</tr>
</thead>
<tbody className="text-sm">
@@ -131,19 +819,25 @@ export default function CfdiPage() {
</span>
</td>
<td className="py-3">
{cfdi.serie || '-'}-{cfdi.folio || '-'}
{cfdi.folio || '-'}
</td>
<td className="py-3">
<div>
<p className="font-medium">
{cfdi.tipo === 'ingreso'
? cfdi.nombreReceptor
: cfdi.nombreEmisor}
<p className="font-medium truncate max-w-[180px]" title={cfdi.nombreEmisor}>
{cfdi.nombreEmisor}
</p>
<p className="text-xs text-muted-foreground">
{cfdi.tipo === 'ingreso'
? cfdi.rfcReceptor
: cfdi.rfcEmisor}
{cfdi.rfcEmisor}
</p>
</div>
</td>
<td className="py-3">
<div>
<p className="font-medium truncate max-w-[180px]" title={cfdi.nombreReceptor}>
{cfdi.nombreReceptor}
</p>
<p className="text-xs text-muted-foreground">
{cfdi.rfcReceptor}
</p>
</div>
</td>
@@ -161,6 +855,18 @@ export default function CfdiPage() {
{cfdi.estado === 'vigente' ? 'Vigente' : 'Cancelado'}
</span>
</td>
{canEdit && (
<td className="py-3">
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(cfdi.id)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</td>
)}
</tr>
))}
</tbody>

View File

@@ -1,28 +1,42 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useQueryClient } from '@tanstack/react-query';
import { Header } from '@/components/layouts/header';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { useTenants, useCreateTenant } from '@/lib/hooks/use-tenants';
import { useTenants, useCreateTenant, useUpdateTenant, useDeleteTenant } from '@/lib/hooks/use-tenants';
import { useTenantViewStore } from '@/stores/tenant-view-store';
import { useAuthStore } from '@/stores/auth-store';
import { Building, Plus, Users, Eye, Calendar } from 'lucide-react';
import { Building, Plus, Users, Eye, Calendar, Pencil, Trash2, X } from 'lucide-react';
import type { Tenant } from '@/lib/api/tenants';
type PlanType = 'starter' | 'business' | 'professional' | 'enterprise';
export default function ClientesPage() {
const { user } = useAuthStore();
const { data: tenants, isLoading } = useTenants();
const createTenant = useCreateTenant();
const updateTenant = useUpdateTenant();
const deleteTenant = useDeleteTenant();
const { setViewingTenant } = useTenantViewStore();
const router = useRouter();
const queryClient = useQueryClient();
const [showForm, setShowForm] = useState(false);
const [formData, setFormData] = useState({
const [editingTenant, setEditingTenant] = useState<Tenant | null>(null);
const [formData, setFormData] = useState<{
nombre: string;
rfc: string;
plan: PlanType;
}>({
nombre: '',
rfc: '',
plan: 'starter' as const,
plan: 'starter',
});
// Only admins can access this page
@@ -47,17 +61,49 @@ export default function ClientesPage() {
e.preventDefault();
try {
if (editingTenant) {
await updateTenant.mutateAsync({ id: editingTenant.id, data: formData });
setEditingTenant(null);
} else {
await createTenant.mutateAsync(formData);
}
setFormData({ nombre: '', rfc: '', plan: 'starter' });
setShowForm(false);
} catch (error) {
console.error('Error creating tenant:', error);
console.error('Error:', error);
}
};
const handleEdit = (tenant: Tenant) => {
setEditingTenant(tenant);
setFormData({
nombre: tenant.nombre,
rfc: tenant.rfc,
plan: tenant.plan as PlanType,
});
setShowForm(true);
};
const handleDelete = async (tenant: Tenant) => {
if (confirm(`¿Eliminar el cliente "${tenant.nombre}"? Esta acción desactivará el cliente.`)) {
try {
await deleteTenant.mutateAsync(tenant.id);
} catch (error) {
console.error('Error deleting tenant:', error);
}
}
};
const handleCancelForm = () => {
setShowForm(false);
setEditingTenant(null);
setFormData({ nombre: '', rfc: '', plan: 'starter' });
};
const handleViewClient = (tenantId: string, tenantName: string) => {
setViewingTenant(tenantId, tenantName);
window.location.href = '/dashboard';
queryClient.invalidateQueries();
router.push('/dashboard');
};
const formatDate = (dateString: string) => {
@@ -126,14 +172,25 @@ export default function ClientesPage() {
</Card>
</div>
{/* Add Client Form */}
{/* Add/Edit Client Form */}
{showForm && (
<Card>
<CardHeader>
<CardTitle className="text-base">Nuevo Cliente</CardTitle>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">
{editingTenant ? 'Editar Cliente' : 'Nuevo Cliente'}
</CardTitle>
<CardDescription>
Registra un nuevo cliente para gestionar su facturación
{editingTenant
? 'Modifica los datos del cliente'
: 'Registra un nuevo cliente para gestionar su facturación'}
</CardDescription>
</div>
<Button variant="ghost" size="icon" onClick={handleCancelForm}>
<X className="h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
@@ -157,6 +214,7 @@ export default function ClientesPage() {
placeholder="XAXX010101000"
maxLength={13}
required
disabled={!!editingTenant} // Can't change RFC after creation
/>
</div>
</div>
@@ -164,8 +222,8 @@ export default function ClientesPage() {
<Label htmlFor="plan">Plan</Label>
<Select
value={formData.plan}
onValueChange={(value: 'starter' | 'business' | 'professional' | 'enterprise') =>
setFormData({ ...formData, plan: value })
onValueChange={(value) =>
setFormData({ ...formData, plan: value as PlanType })
}
>
<SelectTrigger>
@@ -180,11 +238,13 @@ export default function ClientesPage() {
</Select>
</div>
<div className="flex gap-2 justify-end">
<Button type="button" variant="outline" onClick={() => setShowForm(false)}>
<Button type="button" variant="outline" onClick={handleCancelForm}>
Cancelar
</Button>
<Button type="submit" disabled={createTenant.isPending}>
{createTenant.isPending ? 'Creando...' : 'Crear Cliente'}
<Button type="submit" disabled={createTenant.isPending || updateTenant.isPending}>
{editingTenant
? (updateTenant.isPending ? 'Guardando...' : 'Guardar Cambios')
: (createTenant.isPending ? 'Creando...' : 'Crear Cliente')}
</Button>
</div>
</form>
@@ -223,7 +283,7 @@ export default function ClientesPage() {
</div>
</div>
</div>
<div className="flex items-center gap-6">
<div className="flex items-center gap-4">
<div className="text-right">
<div className="flex items-center gap-1 text-sm">
<Users className="h-4 w-4 text-muted-foreground" />
@@ -234,6 +294,7 @@ export default function ClientesPage() {
<span>{formatDate(tenant.createdAt)}</span>
</div>
</div>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
@@ -242,6 +303,24 @@ export default function ClientesPage() {
<Eye className="h-4 w-4 mr-1" />
Ver
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleEdit(tenant)}
title="Editar"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDelete(tenant)}
title="Eliminar"
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
))}

View File

@@ -1,9 +1,11 @@
'use client';
import { useState } from 'react';
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 { PeriodSelector } from '@/components/period-selector';
import { useKpis, useIngresosEgresos, useAlertas, useResumenFiscal } from '@/lib/hooks/use-dashboard';
import {
TrendingUp,
@@ -15,13 +17,13 @@ import {
} from 'lucide-react';
export default function DashboardPage() {
const currentYear = new Date().getFullYear();
const currentMonth = new Date().getMonth() + 1;
const [año, setAño] = useState(new Date().getFullYear());
const [mes, setMes] = useState(new Date().getMonth() + 1);
const { data: kpis, isLoading: kpisLoading } = useKpis(currentYear, currentMonth);
const { data: chartData, isLoading: chartLoading } = useIngresosEgresos(currentYear);
const { data: kpis, isLoading: kpisLoading } = useKpis(año, mes);
const { data: chartData, isLoading: chartLoading } = useIngresosEgresos(año);
const { data: alertas, isLoading: alertasLoading } = useAlertas(5);
const { data: resumenFiscal } = useResumenFiscal(currentYear, currentMonth);
const { data: resumenFiscal } = useResumenFiscal(año, mes);
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', {
@@ -32,7 +34,14 @@ export default function DashboardPage() {
return (
<>
<Header title="Dashboard" />
<Header title="Dashboard">
<PeriodSelector
año={año}
mes={mes}
onAñoChange={setAño}
onMesChange={setMes}
/>
</Header>
<main className="p-6 space-y-6">
{/* KPIs */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">

View File

@@ -5,20 +5,20 @@ 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 { PeriodSelector } from '@/components/period-selector';
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 [año, setAño] = useState(new Date().getFullYear());
const [mes, setMes] = useState(new Date().getMonth() + 1);
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 { data: resumenIva } = useResumenIva(año, mes);
const { data: resumenIsr } = useResumenIsr(año, mes);
const formatCurrency = (value: number) =>
new Intl.NumberFormat('es-MX', {
@@ -29,7 +29,14 @@ export default function ImpuestosPage() {
return (
<>
<Header title="Control de Impuestos" />
<Header title="Control de Impuestos">
<PeriodSelector
año={año}
mes={mes}
onAñoChange={setAño}
onMesChange={setMes}
/>
</Header>
<main className="p-6 space-y-6">
{/* Tabs */}
<div className="flex gap-2">

View File

@@ -17,17 +17,27 @@ export default function DashboardLayout({
children: React.ReactNode;
}) {
const router = useRouter();
const { isAuthenticated } = useAuthStore();
const { isAuthenticated, _hasHydrated } = useAuthStore();
const { theme } = useThemeStore();
const currentTheme = themes[theme];
const layout = currentTheme.layout;
useEffect(() => {
if (!isAuthenticated) {
// Solo verificar autenticación después de que el store se rehidrate
if (_hasHydrated && !isAuthenticated) {
router.push('/login');
}
}, [isAuthenticated, router]);
}, [isAuthenticated, _hasHydrated, router]);
// Mostrar loading mientras se rehidrata el store
if (!_hasHydrated) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="animate-pulse text-muted-foreground">Cargando...</div>
</div>
);
}
if (!isAuthenticated) {
return null;

View File

@@ -4,26 +4,33 @@ import { useState } from 'react';
import { DashboardShell } from '@/components/layouts/dashboard-shell';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { PeriodSelector } from '@/components/period-selector';
import { useEstadoResultados, useFlujoEfectivo, useComparativo, useConcentradoRfc } from '@/lib/hooks/use-reportes';
import { BarChart } from '@/components/charts/bar-chart';
import { formatCurrency } from '@/lib/utils';
import { FileText, TrendingUp, TrendingDown, Users } from 'lucide-react';
export default function ReportesPage() {
const [año] = useState(new Date().getFullYear());
const [año, setAño] = useState(new Date().getFullYear());
const fechaInicio = `${año}-01-01`;
const fechaFin = `${año}-12-31`;
const { data: estadoResultados, isLoading: loadingER } = useEstadoResultados(fechaInicio, fechaFin);
const { data: flujoEfectivo, isLoading: loadingFE } = useFlujoEfectivo(fechaInicio, fechaFin);
const { data: comparativo, isLoading: loadingComp } = useComparativo(año);
const { data: clientes } = useConcentradoRfc('cliente', fechaInicio, fechaFin);
const { data: proveedores } = useConcentradoRfc('proveedor', fechaInicio, fechaFin);
const { data: estadoResultados, isLoading: loadingER, error: errorER } = useEstadoResultados(fechaInicio, fechaFin);
const { data: flujoEfectivo, isLoading: loadingFE, error: errorFE } = useFlujoEfectivo(fechaInicio, fechaFin);
const { data: comparativo, isLoading: loadingComp, error: errorComp } = useComparativo(año);
const { data: clientes, error: errorClientes } = useConcentradoRfc('cliente', fechaInicio, fechaFin);
const { data: proveedores, error: errorProveedores } = useConcentradoRfc('proveedor', fechaInicio, fechaFin);
return (
<DashboardShell
title="Reportes"
description="Analisis financiero y reportes fiscales"
headerContent={
<PeriodSelector
año={año}
onAñoChange={setAño}
showMonth={false}
/>
}
>
<Tabs defaultValue="estado-resultados" className="space-y-4">
<TabsList>
@@ -36,7 +43,11 @@ export default function ReportesPage() {
<TabsContent value="estado-resultados" className="space-y-4">
{loadingER ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : estadoResultados ? (
) : errorER ? (
<div className="text-center py-8 text-destructive">Error: {(errorER as Error).message}</div>
) : !estadoResultados ? (
<div className="text-center py-8 text-muted-foreground">No hay datos disponibles para el período seleccionado</div>
) : (
<>
<div className="grid gap-4 md:grid-cols-4">
<Card>
@@ -118,13 +129,17 @@ export default function ReportesPage() {
</Card>
</div>
</>
) : null}
)}
</TabsContent>
<TabsContent value="flujo-efectivo" className="space-y-4">
{loadingFE ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : flujoEfectivo ? (
) : errorFE ? (
<div className="text-center py-8 text-destructive">Error: {(errorFE as Error).message}</div>
) : !flujoEfectivo ? (
<div className="text-center py-8 text-muted-foreground">No hay datos de flujo de efectivo</div>
) : (
<>
<div className="grid gap-4 md:grid-cols-3">
<Card>
@@ -159,28 +174,26 @@ export default function ReportesPage() {
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Flujo de Efectivo Mensual</CardTitle>
</CardHeader>
<CardContent>
<BarChart
title="Flujo de Efectivo Mensual"
data={flujoEfectivo.entradas.map((e, i) => ({
mes: e.concepto,
ingresos: e.monto,
egresos: flujoEfectivo.salidas[i]?.monto || 0,
}))}
/>
</CardContent>
</Card>
</>
) : null}
)}
</TabsContent>
<TabsContent value="comparativo" className="space-y-4">
{loadingComp ? (
<div className="text-center py-8 text-muted-foreground">Cargando...</div>
) : comparativo ? (
) : errorComp ? (
<div className="text-center py-8 text-destructive">Error: {(errorComp as Error).message}</div>
) : !comparativo ? (
<div className="text-center py-8 text-muted-foreground">No hay datos comparativos</div>
) : (
<>
<div className="grid gap-4 md:grid-cols-3">
<Card>
@@ -213,25 +226,24 @@ export default function ReportesPage() {
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Comparativo Mensual {año}</CardTitle>
</CardHeader>
<CardContent>
<BarChart
title={`Comparativo Mensual ${año}`}
data={comparativo.periodos.map((mes, i) => ({
mes,
ingresos: comparativo.ingresos[i],
egresos: comparativo.egresos[i],
}))}
/>
</CardContent>
</Card>
</>
) : null}
)}
</TabsContent>
<TabsContent value="concentrado" className="space-y-4">
{errorClientes || errorProveedores ? (
<div className="text-center py-8 text-destructive">
Error: {((errorClientes || errorProveedores) as Error).message}
</div>
) : (
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
@@ -242,7 +254,8 @@ export default function ReportesPage() {
</CardHeader>
<CardContent>
<div className="space-y-2">
{clientes?.slice(0, 10).map((c, i) => (
{clientes && clientes.length > 0 ? (
clientes.slice(0, 10).map((c, i) => (
<div key={i} className="flex justify-between items-center py-2 border-b last:border-0">
<div>
<div className="font-medium text-sm">{c.nombre}</div>
@@ -250,7 +263,10 @@ export default function ReportesPage() {
</div>
<span className="font-medium">{formatCurrency(c.totalFacturado)}</span>
</div>
))}
))
) : (
<div className="text-center py-4 text-muted-foreground text-sm">Sin clientes</div>
)}
</div>
</CardContent>
</Card>
@@ -263,7 +279,8 @@ export default function ReportesPage() {
</CardHeader>
<CardContent>
<div className="space-y-2">
{proveedores?.slice(0, 10).map((p, i) => (
{proveedores && proveedores.length > 0 ? (
proveedores.slice(0, 10).map((p, i) => (
<div key={i} className="flex justify-between items-center py-2 border-b last:border-0">
<div>
<div className="font-medium text-sm">{p.nombre}</div>
@@ -271,11 +288,15 @@ export default function ReportesPage() {
</div>
<span className="font-medium">{formatCurrency(p.totalFacturado)}</span>
</div>
))}
))
) : (
<div className="text-center py-4 text-muted-foreground text-sm">Sin proveedores</div>
)}
</div>
</CardContent>
</Card>
</div>
)}
</TabsContent>
</Tabs>
</DashboardShell>

View File

@@ -26,7 +26,7 @@ export default function UsuariosPage() {
const deleteUsuario = useDeleteUsuario();
const [showInvite, setShowInvite] = useState(false);
const [inviteForm, setInviteForm] = useState({ email: '', nombre: '', role: 'visor' as const });
const [inviteForm, setInviteForm] = useState<{ email: string; nombre: string; role: 'admin' | 'contador' | 'visor' }>({ email: '', nombre: '', role: 'visor' });
const handleInvite = async (e: React.FormEvent) => {
e.preventDefault();
@@ -52,10 +52,7 @@ export default function UsuariosPage() {
const isAdmin = currentUser?.role === 'admin';
return (
<DashboardShell
title="Usuarios"
description="Gestiona los usuarios de tu empresa"
>
<DashboardShell title="Usuarios">
<div className="space-y-4">
{/* Header */}
<div className="flex justify-between items-center">
@@ -103,7 +100,7 @@ export default function UsuariosPage() {
<Label htmlFor="role">Rol</Label>
<Select
value={inviteForm.role}
onValueChange={(v: 'admin' | 'contador' | 'visor') => setInviteForm({ ...inviteForm, role: v })}
onValueChange={(v) => setInviteForm({ ...inviteForm, role: v as 'admin' | 'contador' | 'visor' })}
>
<SelectTrigger>
<SelectValue />

View File

@@ -4,14 +4,15 @@ import { Header } from './header';
interface DashboardShellProps {
children: React.ReactNode;
title: string;
headerContent?: React.ReactNode;
}
export function DashboardShell({ children, title }: DashboardShellProps) {
export function DashboardShell({ children, title, headerContent }: DashboardShellProps) {
return (
<div className="min-h-screen bg-background">
<Sidebar />
<div className="pl-64">
<Header title={title} />
<Header title={title}>{headerContent}</Header>
<main className="p-6">{children}</main>
</div>
</div>

View File

@@ -15,7 +15,12 @@ const themeIcons: Record<ThemeName, React.ReactNode> = {
const themeOrder: ThemeName[] = ['light', 'vibrant', 'corporate', 'dark'];
export function Header({ title }: { title: string }) {
interface HeaderProps {
title: string;
children?: React.ReactNode;
}
export function Header({ title, children }: HeaderProps) {
const { theme, setTheme } = useThemeStore();
const cycleTheme = () => {
@@ -26,7 +31,10 @@ export function Header({ title }: { title: string }) {
return (
<header className="sticky top-0 z-30 flex h-16 items-center justify-between border-b bg-background/95 backdrop-blur px-6">
<div className="flex items-center gap-4">
<h1 className="text-xl font-semibold">{title}</h1>
{children}
</div>
<div className="flex items-center gap-3">
<TenantSelector />

View File

@@ -0,0 +1,104 @@
'use client';
import { Button } from '@/components/ui/button';
import { ChevronLeft, ChevronRight } from 'lucide-react';
const meses = [
'Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun',
'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'
];
interface PeriodSelectorProps {
año: number;
mes?: number;
onAñoChange: (año: number) => void;
onMesChange?: (mes: number) => void;
showMonth?: boolean;
minYear?: number;
maxYear?: number;
}
export function PeriodSelector({
año,
mes,
onAñoChange,
onMesChange,
showMonth = true,
minYear = 2020,
maxYear = new Date().getFullYear(),
}: PeriodSelectorProps) {
const handlePrev = () => {
if (showMonth && mes && onMesChange) {
if (mes === 1) {
if (año > minYear) {
onMesChange(12);
onAñoChange(año - 1);
}
} else {
onMesChange(mes - 1);
}
} else {
if (año > minYear) {
onAñoChange(año - 1);
}
}
};
const handleNext = () => {
if (showMonth && mes && onMesChange) {
if (mes === 12) {
if (año < maxYear) {
onMesChange(1);
onAñoChange(año + 1);
}
} else {
onMesChange(mes + 1);
}
} else {
if (año < maxYear) {
onAñoChange(año + 1);
}
}
};
const canGoPrev = showMonth && mes
? !(año === minYear && mes === 1)
: año > minYear;
const canGoNext = showMonth && mes
? !(año === maxYear && mes === 12)
: año < maxYear;
const displayText = showMonth && mes
? `${meses[mes - 1]} ${año}`
: `${año}`;
return (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handlePrev}
disabled={!canGoPrev}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="min-w-[90px] text-center text-sm font-medium">
{displayText}
</span>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={handleNext}
disabled={!canGoNext}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
);
}

View File

@@ -1,7 +1,7 @@
'use client';
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { getTenants, type Tenant } from '@/lib/api/tenants';
import { useTenantViewStore } from '@/stores/tenant-view-store';
import { useAuthStore } from '@/stores/auth-store';
@@ -11,6 +11,7 @@ import { cn } from '@/lib/utils';
export function TenantSelector() {
const [open, setOpen] = useState(false);
const { user } = useAuthStore();
const queryClient = useQueryClient();
const { viewingTenantId, viewingTenantName, setViewingTenant, clearViewingTenant } = useTenantViewStore();
const { data: tenants, isLoading } = useQuery({
@@ -57,17 +58,26 @@ export function TenantSelector() {
<Building className="h-4 w-4" />
<span className="max-w-[150px] truncate">{displayName}</span>
{isViewingOther && (
<button
<span
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
clearViewingTenant();
window.location.reload();
queryClient.invalidateQueries();
}}
className="ml-1 p-0.5 rounded hover:bg-primary/20"
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
clearViewingTenant();
queryClient.invalidateQueries();
}
}}
className="ml-1 p-0.5 rounded hover:bg-primary/20 cursor-pointer"
title="Volver a mi empresa"
>
<X className="h-3 w-3" />
</button>
</span>
)}
<ChevronDown className={cn('h-4 w-4 transition-transform', open && 'rotate-180')} />
</button>
@@ -87,7 +97,7 @@ export function TenantSelector() {
onClick={() => {
clearViewingTenant();
setOpen(false);
window.location.reload();
queryClient.invalidateQueries();
}}
className={cn(
'flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors',
@@ -113,9 +123,9 @@ export function TenantSelector() {
<button
key={tenant.id}
onClick={() => {
setViewingTenant(tenant.id, tenant.nombre);
setViewingTenant(tenant.id, tenant.nombre, tenant.rfc);
setOpen(false);
window.location.reload();
queryClient.invalidateQueries();
}}
className={cn(
'flex w-full items-center gap-3 rounded-md px-3 py-2 text-sm hover:bg-accent transition-colors',

View File

@@ -30,3 +30,42 @@ export async function getResumenCfdi(año?: number, mes?: number) {
const response = await apiClient.get(`/cfdi/resumen?${params}`);
return response.data;
}
export interface CreateCfdiData {
uuidFiscal: string;
tipo: 'ingreso' | 'egreso' | 'traslado' | 'nomina' | 'pago';
serie?: string;
folio?: string;
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;
formaPago?: string;
usoCfdi?: string;
estado?: string;
}
export async function createCfdi(data: CreateCfdiData): Promise<Cfdi> {
const response = await apiClient.post<Cfdi>('/cfdi', data);
return response.data;
}
export async function createManyCfdis(cfdis: CreateCfdiData[]): Promise<{ count: number }> {
const response = await apiClient.post<{ count: number; message: string }>('/cfdi/bulk', { cfdis });
return response.data;
}
export async function deleteCfdi(id: string): Promise<void> {
await apiClient.delete(`/cfdi/${id}`);
}

View File

@@ -34,3 +34,21 @@ export async function createTenant(data: CreateTenantData): Promise<Tenant> {
const response = await apiClient.post<Tenant>('/tenants', data);
return response.data;
}
export interface UpdateTenantData {
nombre?: string;
rfc?: string;
plan?: 'starter' | 'business' | 'professional' | 'enterprise';
cfdiLimit?: number;
usersLimit?: number;
active?: boolean;
}
export async function updateTenant(id: string, data: UpdateTenantData): Promise<Tenant> {
const response = await apiClient.put<Tenant>(`/tenants/${id}`, data);
return response.data;
}
export async function deleteTenant(id: string): Promise<void> {
await apiClient.delete(`/tenants/${id}`);
}

View File

@@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import * as cfdiApi from '@/lib/api/cfdi';
import type { CfdiFilters } from '@horux/shared';
import type { CreateCfdiData } from '@/lib/api/cfdi';
export function useCfdis(filters: CfdiFilters) {
return useQuery({
@@ -23,3 +24,42 @@ export function useResumenCfdi(año?: number, mes?: number) {
queryFn: () => cfdiApi.getResumenCfdi(año, mes),
});
}
export function useCreateCfdi() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateCfdiData) => cfdiApi.createCfdi(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cfdis'] });
queryClient.invalidateQueries({ queryKey: ['cfdi-resumen'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
},
});
}
export function useCreateManyCfdis() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (cfdis: CreateCfdiData[]) => cfdiApi.createManyCfdis(cfdis),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cfdis'] });
queryClient.invalidateQueries({ queryKey: ['cfdi-resumen'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
},
});
}
export function useDeleteCfdi() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => cfdiApi.deleteCfdi(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cfdis'] });
queryClient.invalidateQueries({ queryKey: ['cfdi-resumen'] });
queryClient.invalidateQueries({ queryKey: ['dashboard'] });
},
});
}

View File

@@ -1,5 +1,5 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getTenants, createTenant, type CreateTenantData } from '@/lib/api/tenants';
import { getTenants, createTenant, updateTenant, deleteTenant, type CreateTenantData, type UpdateTenantData } from '@/lib/api/tenants';
export function useTenants() {
return useQuery({
@@ -18,3 +18,25 @@ export function useCreateTenant() {
},
});
}
export function useUpdateTenant() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateTenantData }) => updateTenant(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tenants'] });
},
});
}
export function useDeleteTenant() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => deleteTenant(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['tenants'] });
},
});
}

View File

@@ -1,9 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
transpilePackages: ['@horux/shared'],
experimental: {
typedRoutes: true,
},
};
module.exports = nextConfig;

View File

@@ -5,9 +5,11 @@ import type { UserInfo } from '@horux/shared';
interface AuthState {
user: UserInfo | null;
isAuthenticated: boolean;
_hasHydrated: boolean;
setUser: (user: UserInfo | null) => void;
setTokens: (accessToken: string, refreshToken: string) => void;
logout: () => void;
setHasHydrated: (state: boolean) => void;
}
export const useAuthStore = create<AuthState>()(
@@ -15,6 +17,7 @@ export const useAuthStore = create<AuthState>()(
(set) => ({
user: null,
isAuthenticated: false,
_hasHydrated: false,
setUser: (user) => set({ user, isAuthenticated: !!user }),
setTokens: (accessToken, refreshToken) => {
localStorage.setItem('accessToken', accessToken);
@@ -25,10 +28,14 @@ export const useAuthStore = create<AuthState>()(
localStorage.removeItem('refreshToken');
set({ user: null, isAuthenticated: false });
},
setHasHydrated: (state) => set({ _hasHydrated: state }),
}),
{
name: 'horux-auth',
partialize: (state) => ({ user: state.user, isAuthenticated: state.isAuthenticated }),
onRehydrateStorage: () => (state) => {
state?.setHasHydrated(true);
},
}
)
);

View File

@@ -4,7 +4,8 @@ import { persist } from 'zustand/middleware';
interface TenantViewState {
viewingTenantId: string | null;
viewingTenantName: string | null;
setViewingTenant: (id: string | null, name: string | null) => void;
viewingTenantRfc: string | null;
setViewingTenant: (id: string | null, name: string | null, rfc?: string | null) => void;
clearViewingTenant: () => void;
}
@@ -13,8 +14,9 @@ export const useTenantViewStore = create<TenantViewState>()(
(set) => ({
viewingTenantId: null,
viewingTenantName: null,
setViewingTenant: (id, name) => set({ viewingTenantId: id, viewingTenantName: name }),
clearViewingTenant: () => set({ viewingTenantId: null, viewingTenantName: null }),
viewingTenantRfc: null,
setViewingTenant: (id, name, rfc = null) => set({ viewingTenantId: id, viewingTenantName: name, viewingTenantRfc: rfc }),
clearViewingTenant: () => set({ viewingTenantId: null, viewingTenantName: null, viewingTenantRfc: null }),
}),
{
name: 'horux-tenant-view',

View File

@@ -32,5 +32,5 @@ export function getPlanLimits(plan: Plan) {
}
export function hasFeature(plan: Plan, feature: string): boolean {
return PLANS[plan].features.includes(feature);
return (PLANS[plan].features as readonly string[]).includes(feature);
}

View File

@@ -1,3 +1,5 @@
import type { Role } from '../types/auth';
export const ROLES = {
admin: {
name: 'Administrador',
@@ -13,8 +15,6 @@ export const ROLES = {
},
} as const;
export type Role = keyof typeof ROLES;
export function hasPermission(role: Role, permission: string): boolean {
return ROLES[role].permissions.includes(permission as any);
}

View File

@@ -28,6 +28,7 @@ export interface UserInfo {
role: Role;
tenantId: string;
tenantName: string;
tenantRfc: string;
}
export interface JWTPayload {

9
pnpm-lock.yaml generated
View File

@@ -29,6 +29,9 @@ importers:
cors:
specifier: ^2.8.5
version: 2.8.5
dotenv:
specifier: ^17.2.3
version: 17.2.3
exceljs:
specifier: ^4.4.0
version: 4.4.0
@@ -1309,6 +1312,10 @@ packages:
dom-helpers@5.2.1:
resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==}
dotenv@17.2.3:
resolution: {integrity: sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==}
engines: {node: '>=12'}
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@@ -3316,6 +3323,8 @@ snapshots:
'@babel/runtime': 7.28.6
csstype: 3.2.3
dotenv@17.2.3: {}
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2