'use client'; import { useState, useRef, useCallback, useEffect } from 'react'; import { useDebounce } from '@horux/shared-ui'; import { Header } from '@/components/layouts/header'; import { Card, CardContent, CardHeader, CardTitle, CardDescription, Button, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, Popover, PopoverTrigger, PopoverContent } from '@horux/shared-ui'; import { useCfdis, useCreateCfdi, useDeleteCfdi } from '@/lib/hooks/use-cfdi'; import { createManyCfdis, searchEmisores, searchReceptores, getCfdis, getConceptosList, type EmisorReceptor } from '@/lib/api/cfdi'; import { cancelarFactura, downloadPdf } from '@/lib/api/facturacion'; 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, Download, Printer } from 'lucide-react'; import * as XLSX from 'xlsx'; import { saveAs } from 'file-saver'; 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 { useContribuyenteStore } from '@/stores/contribuyente-store'; import { useQueryClient, useQuery } 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 = 'EMITIDO' | 'RECIBIDO'; const initialFormData: CreateCfdiData = { uuid: '', type: 'EMITIDO', serie: '', folio: '', fechaEmision: new Date().toISOString().split('T')[0], rfcEmisor: '', nombreEmisor: '', rfcReceptor: '', nombreReceptor: '', subtotal: 0, descuento: 0, ivaTraslado: 0, isrRetencion: 0, ivaRetencion: 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['type']; if (rfcEmisor.toUpperCase() === tenantRfcUpper) { tipoFinal = 'EMITIDO'; } else { tipoFinal = 'RECIBIDO'; } // 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 { uuid: uuid.toUpperCase(), type: tipoFinal, serie: comprobante.getAttribute('Serie') || '', folio: comprobante.getAttribute('Folio') || '', fechaEmision, rfcEmisor, nombreEmisor: nombreEmisor || 'Sin nombre', rfcReceptor, nombreReceptor: nombreReceptor || 'Sin nombre', subtotal, descuento, ivaTraslado: totalImpuestosTrasladados, isrRetencion: isrRetenido, ivaRetencion: ivaRetenido, total, moneda: comprobante.getAttribute('Moneda') || 'MXN', tipoCambio: parseFloat(comprobante.getAttribute('TipoCambio') || '1'), tipoComprobante: comprobante.getAttribute('TipoDeComprobante') || '', metodoPago: comprobante.getAttribute('MetodoPago') || '', formaPago: comprobante.getAttribute('FormaPago') || '', usoCfdi, }; } catch (error) { console.error('Error parsing XML:', error); return null; } } const TIPO_COMPROBANTE_LABELS: Record = { I: 'Ingreso', E: 'Egreso', P: 'Pago', T: 'Traslado', N: 'Nómina', }; function formatTipoComprobante(value: string | null | undefined): string { if (!value) return ''; const upper = value.toUpperCase(); return TIPO_COMPROBANTE_LABELS[upper] ? `${upper} - ${TIPO_COMPROBANTE_LABELS[upper]}` : upper; } // 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 { selectedContribuyenteId } = useContribuyenteStore(); 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: '', }); // Reset pagination and filters when contribuyente changes useEffect(() => { setFilters({ page: 1, limit: 20 }); setSearchTerm(''); setColumnFilters({ fechaInicio: '', fechaFin: '', emisor: '', receptor: '' }); }, [selectedContribuyenteId]); const [openFilter, setOpenFilter] = useState<'fecha' | 'emisor' | 'receptor' | null>(null); const [emisorSuggestions, setEmisorSuggestions] = useState([]); const [receptorSuggestions, setReceptorSuggestions] = useState([]); const [loadingEmisor, setLoadingEmisor] = useState(false); const [loadingReceptor, setLoadingReceptor] = useState(false); const [showForm, setShowForm] = useState(false); // Debounced values for autocomplete const debouncedEmisor = useDebounce(columnFilters.emisor, 300); const debouncedReceptor = useDebounce(columnFilters.receptor, 300); // Fetch emisor suggestions when debounced value changes useEffect(() => { if (debouncedEmisor.length < 2) { setEmisorSuggestions([]); return; } setLoadingEmisor(true); searchEmisores(debouncedEmisor) .then(setEmisorSuggestions) .catch(() => setEmisorSuggestions([])) .finally(() => setLoadingEmisor(false)); }, [debouncedEmisor]); // Fetch receptor suggestions when debounced value changes useEffect(() => { if (debouncedReceptor.length < 2) { setReceptorSuggestions([]); return; } setLoadingReceptor(true); searchReceptores(debouncedReceptor) .then(setReceptorSuggestions) .catch(() => setReceptorSuggestions([])) .finally(() => setLoadingReceptor(false)); }, [debouncedReceptor]); 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); // Pestañas: CFDIs (lista actual) | Conceptos (tabla cross-CFDI con conceptos). // Conceptos hereda los mismos filtros aplicados a CFDIs + tiene filtros propios. const [activeTab, setActiveTab] = useState<'cfdis' | 'conceptos'>('cfdis'); // Filtros locales de la pestaña Conceptos (no compartidos con CFDIs). // Popovers en headers UUID, Clave, Descripción + ordenamiento por importe. const [conceptosFilters, setConceptosFilters] = useState<{ uuidLike: string; claveProdServ: string; descripcionConcepto: string; orderBy?: 'fecha' | 'importe'; orderDir?: 'asc' | 'desc'; }>({ uuidLike: '', claveProdServ: '', descripcionConcepto: '' }); const [conceptosOpenFilter, setConceptosOpenFilter] = useState<'uuid' | 'clave' | 'descripcion' | null>(null); const conceptosQuery = useQuery({ queryKey: ['cfdi-conceptos', filters, selectedContribuyenteId, conceptosFilters], queryFn: () => getConceptosList({ ...filters, contribuyenteId: selectedContribuyenteId || undefined, uuidLike: conceptosFilters.uuidLike || undefined, claveProdServ: conceptosFilters.claveProdServ || undefined, descripcionConcepto: conceptosFilters.descripcionConcepto || undefined, orderBy: conceptosFilters.orderBy, orderDir: conceptosFilters.orderDir, }), enabled: activeTab === 'conceptos', }); const toggleImporteSort = () => { setConceptosFilters(prev => { // null → asc → desc → null (o ciclo simple asc ↔ desc si prefieres) const isImporte = prev.orderBy === 'importe'; if (!isImporte) return { ...prev, orderBy: 'importe', orderDir: 'desc' }; if (prev.orderDir === 'desc') return { ...prev, orderBy: 'importe', orderDir: 'asc' }; return { ...prev, orderBy: undefined, orderDir: undefined }; }); setFilters(f => ({ ...f, page: 1 })); }; const createCfdi = useCreateCfdi(); const deleteCfdi = useDeleteCfdi(); // CFDI Viewer state const [viewingCfdi, setViewingCfdi] = useState(null); const [loadingCfdi, setLoadingCfdi] = useState(null); // Cancelación Facturapi state const [cancelTarget, setCancelTarget] = useState(null); const [cancelMotive, setCancelMotive] = useState<'01' | '02' | '03' | '04'>('02'); const [cancelSubstitution, setCancelSubstitution] = useState(''); const [cancelling, setCancelling] = useState(false); const handleViewCfdi = async (id: number) => { setLoadingCfdi(id); try { const cfdi = await getCfdiById(String(id)); setViewingCfdi(cfdi); } catch (error) { console.error('Error loading CFDI:', error); alert('Error al cargar el CFDI'); } finally { setLoadingCfdi(null); } }; const canEdit = user?.role === 'owner' || user?.role === 'cfo' || user?.role === 'contador' || user?.role === 'auxiliar'; const handleSearch = () => { setFilters({ ...filters, search: searchTerm, page: 1 }); }; // Export to Excel const [exporting, setExporting] = useState(false); const exportToExcel = async () => { if (!data?.data.length) return; setExporting(true); try { // Fetch TODOS los CFDIs que cumplen los filtros (no solo la página visible). // Topamos a 10,000 filas — Excel maneja 1M, pero >10k es más reporte que // exploración, conviene empujar al user a filtrar más fino. // NOTA: el hook `useCfdis` inyecta contribuyenteId automáticamente; al // bypassearlo aquí (fetch directo) hay que inyectarlo manualmente o el // export trae CFDIs de TODO el despacho en lugar del contribuyente activo. const EXPORT_MAX = 10_000; const fullResponse = await getCfdis({ ...filters, contribuyenteId: selectedContribuyenteId || undefined, page: 1, limit: EXPORT_MAX, }); const allRows = fullResponse.data; if (fullResponse.total > EXPORT_MAX) { const proceed = confirm( `Hay ${fullResponse.total.toLocaleString('es-MX')} CFDIs que cumplen los filtros, ` + `pero el export está topado a ${EXPORT_MAX.toLocaleString('es-MX')} filas (los más recientes). ` + `Ajusta filtros para precisar. ¿Continuar con las primeras ${EXPORT_MAX.toLocaleString('es-MX')} filas?` ); if (!proceed) { setExporting(false); return; } } const exportData = allRows.map(cfdi => ({ 'Fecha Emisión': new Date(cfdi.fechaEmision).toLocaleDateString('es-MX'), 'Tipo Comprobante': formatTipoComprobante(cfdi.tipoComprobante), 'Uso CFDI': (cfdi as any).usoCfdi || '', 'Serie': cfdi.serie || '', 'Folio': cfdi.folio || '', 'RFC Emisor': cfdi.rfcEmisor, 'Nombre Emisor': cfdi.nombreEmisor, 'RFC Receptor': cfdi.rfcReceptor, 'Nombre Receptor': cfdi.nombreReceptor, 'Subtotal': cfdi.subtotal, 'Descuento': cfdi.descuento || 0, 'IVA': cfdi.ivaTraslado, 'Total': cfdi.total, 'Moneda': cfdi.moneda, 'Método Pago': cfdi.metodoPago || '', // Usamos `saldoPendienteMxn` (valor calculado por utils/saldo.ts) porque // `saldoPendiente` (moneda original) no se backfilleó — todos NULL. // PUE / P / E no tienen saldo conceptual → null en BD; lo dejamos // vacío en Excel para no confundir "0 = pagado" con "no aplica". 'Saldo Pendiente': cfdi.saldoPendienteMxn ?? '', 'Estatus': cfdi.status === 'Vigente' || cfdi.status === '1' ? 'Vigente' : 'Cancelado', 'Fecha Cancelación': cfdi.fechaCancelacion ? new Date(cfdi.fechaCancelacion).toLocaleDateString('es-MX') : '', 'UUID': cfdi.uuid, })); const ws = XLSX.utils.json_to_sheet(exportData); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, 'CFDIs'); // Auto-size columns const colWidths = Object.keys(exportData[0]).map(key => ({ wch: Math.max(key.length, ...exportData.map(row => String(row[key as keyof typeof row]).length)) })); ws['!cols'] = colWidths; const excelBuffer = XLSX.write(wb, { bookType: 'xlsx', type: 'array' }); const blob = new Blob([excelBuffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); const fileName = `cfdis_${new Date().toISOString().split('T')[0]}.xlsx`; saveAs(blob, fileName); } catch (error) { console.error('Error exporting:', error); alert('Error al exportar'); } finally { setExporting(false); } }; // Export de la pestaña Conceptos: trae todos los conceptos que cumplen los // filtros actuales, descartando todas las columnas que terminan en `_mxn`. const exportConceptosToExcel = async () => { setExporting(true); try { const EXPORT_MAX = 10_000; const fullResponse = await getConceptosList({ ...filters, contribuyenteId: selectedContribuyenteId || undefined, uuidLike: conceptosFilters.uuidLike || undefined, claveProdServ: conceptosFilters.claveProdServ || undefined, descripcionConcepto: conceptosFilters.descripcionConcepto || undefined, orderBy: conceptosFilters.orderBy, orderDir: conceptosFilters.orderDir, page: 1, limit: EXPORT_MAX, }); const allRows = fullResponse.data; if (!allRows.length) { alert('No hay conceptos que cumplan los filtros'); setExporting(false); return; } if (fullResponse.total > EXPORT_MAX) { const proceed = confirm( `Hay ${fullResponse.total.toLocaleString('es-MX')} conceptos que cumplen los filtros, ` + `pero el export está topado a ${EXPORT_MAX.toLocaleString('es-MX')} filas. ` + `¿Continuar con las primeras ${EXPORT_MAX.toLocaleString('es-MX')}?` ); if (!proceed) { setExporting(false); return; } } // Filtrar columnas: quitar todas las que terminan en _mxn (per requerimiento). // También quitamos `id`/`cfdi_id` (internas, sin valor para el contador). const exportData = allRows.map(row => { const out: Record = {}; for (const [key, val] of Object.entries(row)) { if (key.endsWith('_mxn') || key === 'id' || key === 'cfdi_id') continue; // Formatear fecha si aplica if (key === 'fechaEmision' && typeof val === 'string') { out['Fecha Emisión'] = new Date(val).toLocaleDateString('es-MX'); } else { out[key] = val; } } return out; }); const ws = XLSX.utils.json_to_sheet(exportData); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, 'Conceptos'); const colWidths = Object.keys(exportData[0]).map(key => ({ wch: Math.max(key.length, ...exportData.map(row => String(row[key as keyof typeof row] ?? '').length)) })); ws['!cols'] = colWidths; const buf = XLSX.write(wb, { bookType: 'xlsx', type: 'array' }); const blob = new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }); saveAs(blob, `cfdi_conceptos_${new Date().toISOString().split('T')[0]}.xlsx`); } catch (error) { console.error('Error exportando conceptos:', error); alert('Error al exportar conceptos'); } finally { setExporting(false); } }; const exportSingleCfdiToExcel = (cfdi: Cfdi) => { const row = { 'Fecha Emisión': new Date(cfdi.fechaEmision).toLocaleDateString('es-MX'), 'Tipo Comprobante': formatTipoComprobante(cfdi.tipoComprobante), 'Uso CFDI': (cfdi as any).usoCfdi || '', 'Serie': cfdi.serie || '', 'Folio': cfdi.folio || '', 'RFC Emisor': cfdi.rfcEmisor, 'Nombre Emisor': cfdi.nombreEmisor, 'RFC Receptor': cfdi.rfcReceptor, 'Nombre Receptor': cfdi.nombreReceptor, 'Subtotal': cfdi.subtotal, 'Descuento': cfdi.descuento || 0, 'IVA': cfdi.ivaTraslado, 'Total': cfdi.total, 'Moneda': cfdi.moneda, 'Método Pago': cfdi.metodoPago || '', // Usamos `saldoPendienteMxn` (valor calculado por utils/saldo.ts) porque // `saldoPendiente` (moneda original) no se backfilleó — todos NULL. // PUE / P / E no tienen saldo conceptual → null en BD; lo dejamos // vacío en Excel para no confundir "0 = pagado" con "no aplica". 'Saldo Pendiente': cfdi.saldoPendienteMxn ?? '', 'Estatus': cfdi.status === 'Vigente' || cfdi.status === '1' ? 'Vigente' : 'Cancelado', 'Fecha Cancelación': cfdi.fechaCancelacion ? new Date(cfdi.fechaCancelacion).toLocaleDateString('es-MX') : '', 'UUID': cfdi.uuid, }; const ws = XLSX.utils.json_to_sheet([row]); ws['!cols'] = Object.keys(row).map((key) => ({ wch: Math.max(key.length, String(row[key as keyof typeof row] ?? '').length), })); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, 'CFDI'); const buffer = XLSX.write(wb, { bookType: 'xlsx', type: 'array' }); const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', }); const idParte = [cfdi.serie, cfdi.folio].filter(Boolean).join('-') || cfdi.uuid.slice(0, 8); saveAs(blob, `cfdi_${idParte}.xlsx`); }; const selectEmisor = (emisor: EmisorReceptor) => { setColumnFilters(prev => ({ ...prev, emisor: emisor.nombre })); setEmisorSuggestions([]); }; const selectReceptor = (receptor: EmisorReceptor) => { setColumnFilters(prev => ({ ...prev, receptor: receptor.nombre })); setReceptorSuggestions([]); }; 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.uuid) { 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 = ''; } }; // Keyboard shortcuts - Esc to close popovers and forms useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { // Close open filter popovers if (openFilter !== null) { setOpenFilter(null); return; } // Close forms if (showForm) { setShowForm(false); return; } if (showBulkForm) { setShowBulkForm(false); clearXmlFiles(); return; } } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [openFilter, showForm, showBulkForm]); const cancelUpload = () => { uploadAbortRef.current = true; setUploadProgress(prev => ({ ...prev, status: 'idle' })); }; const handleDelete = async (id: number) => { if (confirm('¿Eliminar este CFDI?')) { try { await deleteCfdi.mutateAsync(String(id)); } catch (error) { console.error('Error deleting CFDI:', error); } } }; const openCancelDialog = (cfdi: any) => { setCancelTarget(cfdi); setCancelMotive('02'); setCancelSubstitution(''); }; const handleCancelFactura = async () => { if (!cancelTarget) return; if (cancelMotive === '01' && cancelSubstitution.trim().length !== 36) { alert('El motivo 01 requiere el UUID completo (36 caracteres) de la factura que sustituye a esta.'); return; } setCancelling(true); try { await cancelarFactura(cancelTarget.uuid, cancelMotive, cancelMotive === '01' ? cancelSubstitution.trim() : undefined); await queryClient.invalidateQueries({ queryKey: ['cfdis'] }); setCancelTarget(null); alert('Factura cancelada. El estatus final depende del SAT (puede quedar en "pendiente" si requiere aceptación del receptor).'); } catch (err: any) { alert(err?.response?.data?.message || err?.message || 'Error al cancelar la factura'); } finally { setCancelling(false); } }; const calculateTotal = () => { const subtotal = formData.subtotal || 0; const descuento = formData.descuento || 0; const iva = formData.ivaTraslado || 0; const isrRetencion = formData.isrRetencion || 0; const ivaRetencion = formData.ivaRetencion || 0; return subtotal - descuento + iva - isrRetencion - ivaRetencion; }; 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, uuid: 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 })} required />
setFormData({ ...formData, moneda: e.target.value.toUpperCase() })} placeholder="MXN" />

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, ivaTraslado: parseFloat(e.target.value) || 0 })} />
setFormData({ ...formData, isrRetencion: parseFloat(e.target.value) || 0 })} />
setFormData({ ...formData, ivaRetencion: 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 && (
)} ) : (