'use client'; import { useState, useRef, useCallback } from 'react'; 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 { useCfdis, useCreateCfdi, useDeleteCfdi } from '@/lib/hooks/use-cfdi'; import { createManyCfdis } from '@/lib/api/cfdi'; import type { CfdiFilters, TipoCfdi, Cfdi } from '@horux/shared'; import type { CreateCfdiData } from '@/lib/api/cfdi'; import { FileText, Search, ChevronLeft, ChevronRight, Plus, Upload, Trash2, X, FileUp, CheckCircle, AlertCircle, Loader2, Eye, Filter, XCircle, Calendar, User, Building2 } from 'lucide-react'; import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'; import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal'; import { getCfdiById } from '@/lib/api/cfdi'; import { useAuthStore } from '@/stores/auth-store'; import { useTenantViewStore } from '@/stores/tenant-view-store'; import { useQueryClient } from '@tanstack/react-query'; // Upload progress state interface UploadProgress { status: 'idle' | 'parsing' | 'uploading' | 'complete' | 'error'; totalFiles: number; parsedFiles: number; validFiles: number; currentBatch: number; totalBatches: number; uploaded: number; duplicates: number; errors: number; errorMessages: string[]; } 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; } } // Chunk size for batch uploads const PARSE_CHUNK_SIZE = 500; // Parse 500 files at a time const UPLOAD_CHUNK_SIZE = 200; // Upload 200 CFDIs per request export default function CfdiPage() { const { user } = useAuthStore(); const { viewingTenantRfc } = useTenantViewStore(); const fileInputRef = useRef(null); const queryClient = useQueryClient(); // 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 [columnFilters, setColumnFilters] = useState({ fechaInicio: '', fechaFin: '', emisor: '', receptor: '', }); const [openFilter, setOpenFilter] = useState<'fecha' | 'emisor' | 'receptor' | null>(null); const [showForm, setShowForm] = useState(false); const [showBulkForm, setShowBulkForm] = useState(false); const [formData, setFormData] = useState(initialFormData); const [bulkData, setBulkData] = useState(''); const [uploadMode, setUploadMode] = useState<'xml' | 'json'>('xml'); const [jsonUploading, setJsonUploading] = useState(false); // Optimized upload state const [uploadProgress, setUploadProgress] = useState({ status: 'idle', totalFiles: 0, parsedFiles: 0, validFiles: 0, currentBatch: 0, totalBatches: 0, uploaded: 0, duplicates: 0, errors: 0, errorMessages: [] }); const [parsedCfdis, setParsedCfdis] = useState([]); const uploadAbortRef = useRef(false); const { data, isLoading } = useCfdis(filters); const createCfdi = useCreateCfdi(); const deleteCfdi = useDeleteCfdi(); // CFDI Viewer state const [viewingCfdi, setViewingCfdi] = useState(null); const [loadingCfdi, setLoadingCfdi] = useState(null); const handleViewCfdi = async (id: string) => { setLoadingCfdi(id); try { const cfdi = await getCfdiById(id); setViewingCfdi(cfdi); } catch (error) { console.error('Error loading CFDI:', error); alert('Error al cargar el CFDI'); } finally { setLoadingCfdi(null); } }; const canEdit = user?.role === 'admin' || user?.role === 'contador'; const handleSearch = () => { setFilters({ ...filters, search: searchTerm, page: 1 }); }; const applyDateFilter = () => { setFilters({ ...filters, fechaInicio: columnFilters.fechaInicio || undefined, fechaFin: columnFilters.fechaFin || undefined, page: 1, }); setOpenFilter(null); }; const applyEmisorFilter = () => { setFilters({ ...filters, emisor: columnFilters.emisor || undefined, page: 1, }); setOpenFilter(null); }; const applyReceptorFilter = () => { setFilters({ ...filters, receptor: columnFilters.receptor || undefined, page: 1, }); setOpenFilter(null); }; const clearDateFilter = () => { setColumnFilters({ ...columnFilters, fechaInicio: '', fechaFin: '' }); setFilters({ ...filters, fechaInicio: undefined, fechaFin: undefined, page: 1 }); setOpenFilter(null); }; const clearEmisorFilter = () => { setColumnFilters({ ...columnFilters, emisor: '' }); setFilters({ ...filters, emisor: undefined, page: 1 }); setOpenFilter(null); }; const clearReceptorFilter = () => { setColumnFilters({ ...columnFilters, receptor: '' }); setFilters({ ...filters, receptor: undefined, page: 1 }); setOpenFilter(null); }; const hasDateFilter = filters.fechaInicio || filters.fechaFin; const hasEmisorFilter = filters.emisor; const hasReceptorFilter = filters.receptor; const hasActiveColumnFilters = hasDateFilter || hasEmisorFilter || hasReceptorFilter; const handleFilterType = (tipo?: TipoCfdi) => { 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(); setJsonUploading(true); 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(cfdis); alert(`Se crearon ${result.inserted} CFDIs exitosamente`); setBulkData(''); setShowBulkForm(false); queryClient.invalidateQueries({ queryKey: ['cfdis'] }); } catch (error: any) { alert(error.message || 'Error al procesar CFDIs'); } finally { setJsonUploading(false); } }; // Optimized: Parse files in chunks to prevent memory issues const handleXmlFilesChange = useCallback(async (e: React.ChangeEvent) => { const files = Array.from(e.target.files || []); if (files.length === 0) return; uploadAbortRef.current = false; setUploadProgress({ status: 'parsing', totalFiles: files.length, parsedFiles: 0, validFiles: 0, currentBatch: 0, totalBatches: 0, uploaded: 0, duplicates: 0, errors: 0, errorMessages: [] }); setParsedCfdis([]); const validCfdis: CreateCfdiData[] = []; let parsedCount = 0; let errorCount = 0; // Process in chunks to prevent memory issues for (let i = 0; i < files.length; i += PARSE_CHUNK_SIZE) { if (uploadAbortRef.current) break; const chunk = files.slice(i, i + PARSE_CHUNK_SIZE); // Parse chunk in parallel const results = await Promise.all( chunk.map(async (file) => { try { const text = await file.text(); const data = parseCfdiXml(text, tenantRfc); return data; } catch { return null; } }) ); // Collect valid results results.forEach((data) => { if (data && data.uuidFiscal) { validCfdis.push(data); } else { errorCount++; } }); parsedCount += chunk.length; setUploadProgress(prev => ({ ...prev, parsedFiles: parsedCount, validFiles: validCfdis.length, errors: errorCount })); // Small delay to allow UI to update await new Promise(r => setTimeout(r, 10)); } setParsedCfdis(validCfdis); setUploadProgress(prev => ({ ...prev, status: uploadAbortRef.current ? 'idle' : 'idle', totalBatches: Math.ceil(validCfdis.length / UPLOAD_CHUNK_SIZE) })); }, [tenantRfc]); // Optimized: Upload in batches with progress const handleXmlBulkSubmit = async (e: React.FormEvent) => { e.preventDefault(); if (parsedCfdis.length === 0) { alert('No hay CFDIs validos para cargar'); return; } uploadAbortRef.current = false; const totalBatches = Math.ceil(parsedCfdis.length / UPLOAD_CHUNK_SIZE); setUploadProgress(prev => ({ ...prev, status: 'uploading', currentBatch: 0, totalBatches, uploaded: 0, duplicates: 0, errors: 0, errorMessages: [] })); let totalUploaded = 0; let totalDuplicates = 0; let totalErrors = 0; const allErrors: string[] = []; // Upload in batches for (let i = 0; i < parsedCfdis.length; i += UPLOAD_CHUNK_SIZE) { if (uploadAbortRef.current) break; const batchNumber = Math.floor(i / UPLOAD_CHUNK_SIZE) + 1; const chunk = parsedCfdis.slice(i, i + UPLOAD_CHUNK_SIZE); setUploadProgress(prev => ({ ...prev, currentBatch: batchNumber })); try { const result = await createManyCfdis(chunk, batchNumber, totalBatches, parsedCfdis.length); totalUploaded += result.inserted; totalDuplicates += result.duplicates; totalErrors += result.errors; if (result.errorMessages) { allErrors.push(...result.errorMessages); } setUploadProgress(prev => ({ ...prev, uploaded: totalUploaded, duplicates: totalDuplicates, errors: prev.errors + result.errors, errorMessages: allErrors.slice(0, 20) // Limit error messages })); } catch (error: any) { console.error(`Error en lote ${batchNumber}:`, error); totalErrors += chunk.length; allErrors.push(`Lote ${batchNumber}: ${error.message || 'Error desconocido'}`); setUploadProgress(prev => ({ ...prev, errors: prev.errors + chunk.length, errorMessages: allErrors.slice(0, 20) })); } // Small delay between batches await new Promise(r => setTimeout(r, 100)); } setUploadProgress(prev => ({ ...prev, status: 'complete' })); // Invalidate queries to refresh the list queryClient.invalidateQueries({ queryKey: ['cfdis'] }); }; const clearXmlFiles = () => { uploadAbortRef.current = true; setParsedCfdis([]); setUploadProgress({ status: 'idle', totalFiles: 0, parsedFiles: 0, validFiles: 0, currentBatch: 0, totalBatches: 0, uploaded: 0, duplicates: 0, errors: 0, errorMessages: [] }); if (fileInputRef.current) { fileInputRef.current.value = ''; } }; const cancelUpload = () => { uploadAbortRef.current = true; setUploadProgress(prev => ({ ...prev, status: 'idle' })); }; 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', currency: 'MXN', }).format(value); const formatDate = (dateString: string) => new Date(dateString).toLocaleDateString('es-MX', { day: '2-digit', month: 'short', 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 ( <>
{/* Filters */}
setSearchTerm(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && handleSearch()} />
{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' ? (
{/* File input - only show when idle */} {uploadProgress.status === 'idle' && (
)} {/* Parsing progress */} {uploadProgress.status === 'parsing' && (

Analizando archivos XML...

{uploadProgress.parsedFiles.toLocaleString()} de {uploadProgress.totalFiles.toLocaleString()} archivos

{uploadProgress.validFiles.toLocaleString()} validos {Math.round((uploadProgress.parsedFiles / uploadProgress.totalFiles) * 100)}%
)} {/* Upload progress */} {uploadProgress.status === 'uploading' && (

Subiendo CFDIs al servidor...

Lote {uploadProgress.currentBatch} de {uploadProgress.totalBatches}

{uploadProgress.uploaded.toLocaleString()}

Cargados

{uploadProgress.duplicates.toLocaleString()}

Duplicados

{uploadProgress.errors.toLocaleString()}

Errores

)} {/* Upload complete */} {uploadProgress.status === 'complete' && (

Carga completada

Se procesaron {uploadProgress.validFiles.toLocaleString()} archivos

{uploadProgress.uploaded.toLocaleString()}

Cargados

{uploadProgress.duplicates.toLocaleString()}

Duplicados

{uploadProgress.errors.toLocaleString()}

Errores

{uploadProgress.errorMessages.length > 0 && (

Errores:

    {uploadProgress.errorMessages.map((err, i) => (
  • {err}
  • ))}
)}
)} {/* Ready to upload - show summary and upload button */} {uploadProgress.status === 'idle' && parsedCfdis.length > 0 && (

{parsedCfdis.length.toLocaleString()} CFDIs listos para cargar

Se enviaran en {Math.ceil(parsedCfdis.length / UPLOAD_CHUNK_SIZE)} lotes de {UPLOAD_CHUNK_SIZE} registros

)} {/* Initial state - no files */} {uploadProgress.status === 'idle' && parsedCfdis.length === 0 && uploadProgress.totalFiles === 0 && (
)} ) : (