'use client'; import { useState, useRef } 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, useCreateManyCfdis, useDeleteCfdi } from '@/lib/hooks/use-cfdi'; import type { CfdiFilters, TipoCfdi } from '@horux/shared'; 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 }); }; 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(); 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', 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' ? (
{/* 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}

)}
))}
)}
) : (