From c3ce7199af5b2b0d8caf5e2fdd7fa84406dbe501 Mon Sep 17 00:00:00 2001 From: Consultoria AS Date: Thu, 22 Jan 2026 06:51:53 +0000 Subject: [PATCH] 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 --- README.md | 53 +- apps/api/package.json | 5 +- apps/api/src/app.ts | 10 +- apps/api/src/config/env.ts | 10 + apps/api/src/controllers/cfdi.controller.ts | 67 ++ .../src/controllers/reportes.controller.ts | 2 + .../api/src/controllers/tenants.controller.ts | 37 + apps/api/src/routes/cfdi.routes.ts | 3 + apps/api/src/routes/tenants.routes.ts | 2 + apps/api/src/services/auth.service.ts | 2 + apps/api/src/services/cfdi.service.ts | 141 ++++ apps/api/src/services/reportes.service.ts | 89 ++- apps/api/src/services/tenants.service.ts | 40 + apps/web/app/(dashboard)/alertas/page.tsx | 5 +- apps/web/app/(dashboard)/calendario/page.tsx | 5 +- apps/web/app/(dashboard)/cfdi/page.tsx | 734 +++++++++++++++++- apps/web/app/(dashboard)/clientes/page.tsx | 131 +++- apps/web/app/(dashboard)/dashboard/page.tsx | 21 +- apps/web/app/(dashboard)/impuestos/page.tsx | 19 +- apps/web/app/(dashboard)/layout.tsx | 16 +- apps/web/app/(dashboard)/reportes/page.tsx | 191 +++-- apps/web/app/(dashboard)/usuarios/page.tsx | 9 +- .../components/layouts/dashboard-shell.tsx | 5 +- apps/web/components/layouts/header.tsx | 12 +- apps/web/components/period-selector.tsx | 104 +++ apps/web/components/tenant-selector.tsx | 26 +- apps/web/lib/api/cfdi.ts | 39 + apps/web/lib/api/tenants.ts | 18 + apps/web/lib/hooks/use-cfdi.ts | 42 +- apps/web/lib/hooks/use-tenants.ts | 24 +- apps/web/next.config.js | 3 - apps/web/stores/auth-store.ts | 7 + apps/web/stores/tenant-view-store.ts | 8 +- packages/shared/src/constants/plans.ts | 2 +- packages/shared/src/constants/roles.ts | 4 +- packages/shared/src/types/auth.ts | 1 + pnpm-lock.yaml | 9 + 37 files changed, 1680 insertions(+), 216 deletions(-) create mode 100644 apps/web/components/period-selector.tsx diff --git a/README.md b/README.md index f50f7db..1fe1d26 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/apps/api/package.json b/apps/api/package.json index c1a5963..68c4f0b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index ba09213..610bbc2 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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) => { diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 0771876..4da91cc 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -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) { diff --git a/apps/api/src/controllers/cfdi.controller.ts b/apps/api/src/controllers/cfdi.controller.ts index a44aede..031534d 100644 --- a/apps/api/src/controllers/cfdi.controller.ts +++ b/apps/api/src/controllers/cfdi.controller.ts @@ -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); + } +} diff --git a/apps/api/src/controllers/reportes.controller.ts b/apps/api/src/controllers/reportes.controller.ts index a2ecf04..1329f55 100644 --- a/apps/api/src/controllers/reportes.controller.ts +++ b/apps/api/src/controllers/reportes.controller.ts @@ -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); } } diff --git a/apps/api/src/controllers/tenants.controller.ts b/apps/api/src/controllers/tenants.controller.ts index e62a394..7556f75 100644 --- a/apps/api/src/controllers/tenants.controller.ts +++ b/apps/api/src/controllers/tenants.controller.ts @@ -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); + } +} diff --git a/apps/api/src/routes/cfdi.routes.ts b/apps/api/src/routes/cfdi.routes.ts index 8a52750..aebf201 100644 --- a/apps/api/src/routes/cfdi.routes.ts +++ b/apps/api/src/routes/cfdi.routes.ts @@ -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 }; diff --git a/apps/api/src/routes/tenants.routes.ts b/apps/api/src/routes/tenants.routes.ts index 94a5b23..bf76f37 100644 --- a/apps/api/src/routes/tenants.routes.ts +++ b/apps/api/src/routes/tenants.routes.ts @@ -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 }; diff --git a/apps/api/src/services/auth.service.ts b/apps/api/src/services/auth.service.ts index 7d296fb..d6098b7 100644 --- a/apps/api/src/services/auth.service.ts +++ b/apps/api/src/services/auth.service.ts @@ -78,6 +78,7 @@ export async function register(data: RegisterRequest): Promise { role: user.role, tenantId: tenant.id, tenantName: tenant.nombre, + tenantRfc: tenant.rfc, }, }; } @@ -140,6 +141,7 @@ export async function login(data: LoginRequest): Promise { role: user.role, tenantId: user.tenantId, tenantName: user.tenant.nombre, + tenantRfc: user.tenant.rfc, }, }; } diff --git a/apps/api/src/services/cfdi.service.ts b/apps/api/src/services/cfdi.service.ts index 9553b7f..a7b5b60 100644 --- a/apps/api/src/services/cfdi.service.ts +++ b/apps/api/src/services/cfdi.service.ts @@ -94,6 +94,147 @@ export async function getCfdiById(schema: string, id: string): Promise { + // 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(` + 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 { + 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 { + 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; diff --git a/apps/api/src/services/reportes.service.ts b/apps/api/src/services/reportes.service.ts index 8079082..6292207 100644 --- a/apps/api/src/services/reportes.service.ts +++ b/apps/api/src/services/reportes.service.ts @@ -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 { - 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 { - 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 { - 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 { if (tipo === 'cliente') { - const data = await prisma.$queryRawUnsafe(` + 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(` + 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 + })); } } diff --git a/apps/api/src/services/tenants.service.ts b/apps/api/src/services/tenants.service.ts index aadbfa4..10e302b 100644 --- a/apps/api/src/services/tenants.service.ts +++ b/apps/api/src/services/tenants.service.ts @@ -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 } + }); +} diff --git a/apps/web/app/(dashboard)/alertas/page.tsx b/apps/web/app/(dashboard)/alertas/page.tsx index c6321a6..d7ab3f2 100644 --- a/apps/web/app/(dashboard)/alertas/page.tsx +++ b/apps/web/app/(dashboard)/alertas/page.tsx @@ -45,10 +45,7 @@ export default function AlertasPage() { }; return ( - +
{/* Stats */}
diff --git a/apps/web/app/(dashboard)/calendario/page.tsx b/apps/web/app/(dashboard)/calendario/page.tsx index 1f6668a..82718e9 100644 --- a/apps/web/app/(dashboard)/calendario/page.tsx +++ b/apps/web/app/(dashboard)/calendario/page.tsx @@ -69,10 +69,7 @@ export default function CalendarioPage() { }; return ( - +
{/* Calendar */} diff --git a/apps/web/app/(dashboard)/cfdi/page.tsx b/apps/web/app/(dashboard)/cfdi/page.tsx index 83803a1..09da209 100644 --- a/apps/web/app/(dashboard)/cfdi/page.tsx +++ b/apps/web/app/(dashboard)/cfdi/page.tsx @@ -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(null); + + // Get the effective tenant RFC (viewing tenant or user's tenant) + const tenantRfc = viewingTenantRfc || user?.tenantRfc || ''; const [filters, setFilters] = useState({ page: 1, limit: 20, }); const [searchTerm, setSearchTerm] = useState(''); + const [showForm, setShowForm] = useState(false); + const [showBulkForm, setShowBulkForm] = useState(false); + const [formData, setFormData] = useState(initialFormData); + const [bulkData, setBulkData] = useState(''); + const [xmlFiles, setXmlFiles] = useState([]); + 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) => { + 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 ( <>
@@ -81,10 +399,378 @@ export default function CfdiPage() { Egresos
+ {canEdit && ( +
+ + +
+ )}
+ {/* Add CFDI Form */} + {showForm && canEdit && ( + + +
+
+ Agregar CFDI + Ingresa los datos del comprobante fiscal +
+ +
+
+ +
+
+
+ +
+ setFormData({ ...formData, uuidFiscal: e.target.value.toUpperCase() })} + placeholder="XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX" + required + /> + +
+
+
+ + +
+
+
+ + setFormData({ ...formData, serie: e.target.value })} + placeholder="A" + /> +
+
+ + setFormData({ ...formData, folio: e.target.value })} + placeholder="001" + /> +
+
+
+ +
+
+ + setFormData({ ...formData, fechaEmision: e.target.value, fechaTimbrado: e.target.value })} + required + /> +
+
+ + setFormData({ ...formData, fechaTimbrado: e.target.value })} + required + /> +
+
+ +
+
+

Emisor

+
+ + setFormData({ ...formData, rfcEmisor: e.target.value.toUpperCase() })} + placeholder="XAXX010101000" + maxLength={13} + required + /> +
+
+ + setFormData({ ...formData, nombreEmisor: e.target.value })} + placeholder="Empresa Emisora SA de CV" + required + /> +
+
+
+

Receptor

+
+ + setFormData({ ...formData, rfcReceptor: e.target.value.toUpperCase() })} + placeholder="XAXX010101000" + maxLength={13} + required + /> +
+
+ + setFormData({ ...formData, nombreReceptor: e.target.value })} + placeholder="Empresa Receptora SA de CV" + required + /> +
+
+
+ +
+
+ + setFormData({ ...formData, subtotal: parseFloat(e.target.value) || 0 })} + required + /> +
+
+ + setFormData({ ...formData, descuento: parseFloat(e.target.value) || 0 })} + /> +
+
+ + setFormData({ ...formData, iva: parseFloat(e.target.value) || 0 })} + /> +
+
+ + setFormData({ ...formData, isrRetenido: parseFloat(e.target.value) || 0 })} + /> +
+
+ + setFormData({ ...formData, ivaRetenido: parseFloat(e.target.value) || 0 })} + /> +
+
+ + setFormData({ ...formData, total: parseFloat(e.target.value) || 0 })} + required + /> +
+
+ +
+ + +
+
+
+
+ )} + + {/* Bulk Upload Form */} + {showBulkForm && canEdit && ( + + +
+
+ Carga Masiva de CFDIs + Sube archivos XML o pega datos en formato JSON +
+ +
+
+ + {/* Mode selector */} +
+ + +
+ + {uploadMode === 'xml' ? ( +
+
+ +
+ + +
+
+ + {/* Show parsed results */} + {parsedXmls.length > 0 && ( +
+
+ + +
+
+ {parsedXmls.map((parsed, idx) => ( +
+ {parsed.data ? ( + + ) : ( + + )} +
+

{parsed.file}

+ {parsed.data ? ( +

+ {parsed.data.tipo === 'ingreso' ? 'Ingreso' : 'Egreso'} - {parsed.data.nombreEmisor?.substring(0, 30)}... - ${parsed.data.total?.toLocaleString()} +

+ ) : ( +

{parsed.error}

+ )} +
+
+ ))} +
+
+ )} + +
+ + +
+
+ ) : ( +
+
+ +