diff --git a/apps/web/app/(dashboard)/cfdi/page.tsx b/apps/web/app/(dashboard)/cfdi/page.tsx index 47cfb34..4ef68db 100644 --- a/apps/web/app/(dashboard)/cfdi/page.tsx +++ b/apps/web/app/(dashboard)/cfdi/page.tsx @@ -12,8 +12,10 @@ import { useCfdis, useCreateCfdi, useDeleteCfdi } from '@/lib/hooks/use-cfdi'; import { createManyCfdis, searchEmisores, searchReceptores, type EmisorReceptor } 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 { FileText, Search, ChevronLeft, ChevronRight, Plus, Upload, Trash2, X, FileUp, CheckCircle, AlertCircle, Loader2, Eye, Filter, XCircle, Calendar, User, Building2, Download, Printer } from 'lucide-react'; import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui/popover'; +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'; @@ -274,6 +276,7 @@ export default function CfdiPage() { .catch(() => setReceptorSuggestions([])) .finally(() => setLoadingReceptor(false)); }, [debouncedReceptor]); + const [showBulkForm, setShowBulkForm] = useState(false); const [formData, setFormData] = useState(initialFormData); const [bulkData, setBulkData] = useState(''); @@ -323,6 +326,54 @@ export default function CfdiPage() { 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 { + const exportData = data.data.map(cfdi => ({ + 'Fecha Emisión': new Date(cfdi.fechaEmision).toLocaleDateString('es-MX'), + 'Tipo': cfdi.tipo === 'ingreso' ? 'Ingreso' : 'Egreso', + '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, + 'IVA': cfdi.iva, + 'Total': cfdi.total, + 'Moneda': cfdi.moneda, + 'Estado': cfdi.estado === 'vigente' ? 'Vigente' : 'Cancelado', + 'UUID': cfdi.uuidFiscal, + })); + + 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); + } + }; + const selectEmisor = (emisor: EmisorReceptor) => { setColumnFilters(prev => ({ ...prev, emisor: emisor.nombre })); setEmisorSuggestions([]); @@ -594,6 +645,32 @@ export default function CfdiPage() { } }; + // 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' })); @@ -681,18 +758,30 @@ export default function CfdiPage() { Egresos - {canEdit && ( -
- - -
- )} + )} + {canEdit && ( + <> + + + + )} + diff --git a/apps/web/components/cfdi/cfdi-viewer-modal.tsx b/apps/web/components/cfdi/cfdi-viewer-modal.tsx index 1b018df..6aa94f2 100644 --- a/apps/web/components/cfdi/cfdi-viewer-modal.tsx +++ b/apps/web/components/cfdi/cfdi-viewer-modal.tsx @@ -6,7 +6,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/u import { Button } from '@/components/ui/button'; import { CfdiInvoice } from './cfdi-invoice'; import { getCfdiXml } from '@/lib/api/cfdi'; -import { Download, FileText, Loader2 } from 'lucide-react'; +import { Download, FileText, Loader2, Printer } from 'lucide-react'; interface CfdiConcepto { descripcion: string; @@ -120,6 +120,45 @@ export function CfdiViewerModal({ cfdi, open, onClose }: CfdiViewerModalProps) { } }; + const handlePrint = () => { + if (!invoiceRef.current) return; + + // Create a print-specific stylesheet + const printStyles = document.createElement('style'); + printStyles.innerHTML = ` + @media print { + body * { + visibility: hidden; + } + #cfdi-print-area, #cfdi-print-area * { + visibility: visible; + } + #cfdi-print-area { + position: absolute; + left: 0; + top: 0; + width: 100%; + padding: 20px; + } + @page { + size: A4; + margin: 15mm; + } + } + `; + document.head.appendChild(printStyles); + + // Add ID to the invoice container for print targeting + invoiceRef.current.id = 'cfdi-print-area'; + + // Trigger print + window.print(); + + // Clean up + document.head.removeChild(printStyles); + invoiceRef.current.removeAttribute('id'); + }; + if (!cfdi) return null; return ( @@ -156,6 +195,16 @@ export function CfdiViewerModal({ cfdi, open, onClose }: CfdiViewerModalProps) { )} XML + diff --git a/apps/web/package.json b/apps/web/package.json index 46ee1a2..823b9d4 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -27,6 +27,7 @@ "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", "date-fns": "^3.6.0", + "file-saver": "^2.0.5", "html2pdf.js": "^0.14.0", "lucide-react": "^0.460.0", "next": "^14.2.0", @@ -35,10 +36,12 @@ "react-hook-form": "^7.53.0", "recharts": "^2.12.0", "tailwind-merge": "^2.5.0", + "xlsx": "^0.18.5", "zod": "^3.23.0", "zustand": "^5.0.0" }, "devDependencies": { + "@types/file-saver": "^2.0.7", "@types/node": "^22.0.0", "@types/react": "^18.3.0", "@types/react-dom": "^18.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36905c5..71278d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -162,6 +162,9 @@ importers: date-fns: specifier: ^3.6.0 version: 3.6.0 + file-saver: + specifier: ^2.0.5 + version: 2.0.5 html2pdf.js: specifier: ^0.14.0 version: 0.14.0 @@ -186,6 +189,9 @@ importers: tailwind-merge: specifier: ^2.5.0 version: 2.6.0 + xlsx: + specifier: ^0.18.5 + version: 0.18.5 zod: specifier: ^3.23.0 version: 3.25.76 @@ -193,6 +199,9 @@ importers: specifier: ^5.0.0 version: 5.0.10(@types/react@18.3.27)(react@18.3.1)(use-sync-external-store@1.6.0(react@18.3.1)) devDependencies: + '@types/file-saver': + specifier: ^2.0.7 + version: 2.0.7 '@types/node': specifier: ^22.0.0 version: 22.19.7 @@ -1070,6 +1079,9 @@ packages: '@types/express@5.0.6': resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + '@types/file-saver@2.0.7': + resolution: {integrity: sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==} + '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} @@ -1135,6 +1147,10 @@ packages: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} + adler-32@1.3.1: + resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==} + engines: {node: '>=0.8'} + adm-zip@0.5.16: resolution: {integrity: sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==} engines: {node: '>=12.0'} @@ -1281,6 +1297,10 @@ packages: resolution: {integrity: sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==} engines: {node: '>=10.0.0'} + cfb@1.2.2: + resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} + engines: {node: '>=0.8'} + chainsaw@0.1.0: resolution: {integrity: sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==} @@ -1298,6 +1318,10 @@ packages: resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} engines: {node: '>=6'} + codepage@1.15.0: + resolution: {integrity: sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==} + engines: {node: '>=0.8'} + combined-stream@1.0.8: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} @@ -1550,6 +1574,9 @@ packages: fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + file-saver@2.0.5: + resolution: {integrity: sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -1575,6 +1602,10 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + frac@1.1.2: + resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} + engines: {node: '>=0.8'} + fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -2299,6 +2330,10 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + ssf@0.11.2: + resolution: {integrity: sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==} + engines: {node: '>=0.8'} + stackblur-canvas@2.7.0: resolution: {integrity: sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==} engines: {node: '>=0.1.14'} @@ -2509,9 +2544,22 @@ packages: victory-vendor@36.9.2: resolution: {integrity: sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==} + wmf@1.0.2: + resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} + engines: {node: '>=0.8'} + + word@0.3.0: + resolution: {integrity: sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==} + engines: {node: '>=0.8'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + xlsx@0.18.5: + resolution: {integrity: sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==} + engines: {node: '>=0.8'} + hasBin: true + xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} @@ -3296,6 +3344,8 @@ snapshots: '@types/express-serve-static-core': 5.1.1 '@types/serve-static': 2.2.0 + '@types/file-saver@2.0.7': {} + '@types/http-errors@2.0.5': {} '@types/jsonwebtoken@9.0.10': @@ -3358,6 +3408,8 @@ snapshots: mime-types: 2.1.35 negotiator: 0.6.3 + adler-32@1.3.1: {} + adm-zip@0.5.16: {} any-promise@1.3.0: {} @@ -3542,6 +3594,11 @@ snapshots: svg-pathdata: 6.0.3 optional: true + cfb@1.2.2: + dependencies: + adler-32: 1.3.1 + crc-32: 1.2.2 + chainsaw@0.1.0: dependencies: traverse: 0.3.9 @@ -3566,6 +3623,8 @@ snapshots: clsx@2.1.1: {} + codepage@1.15.0: {} + combined-stream@1.0.8: dependencies: delayed-stream: 1.0.0 @@ -3846,6 +3905,8 @@ snapshots: fflate@0.8.2: {} + file-saver@2.0.5: {} + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 @@ -3874,6 +3935,8 @@ snapshots: forwarded@0.2.0: {} + frac@1.1.2: {} + fraction.js@5.3.4: {} fresh@0.5.2: {} @@ -4580,6 +4643,10 @@ snapshots: split2@4.2.0: {} + ssf@0.11.2: + dependencies: + frac: 1.1.2 + stackblur-canvas@2.7.0: optional: true @@ -4802,8 +4869,22 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 + wmf@1.0.2: {} + + word@0.3.0: {} + wrappy@1.0.2: {} + xlsx@0.18.5: + dependencies: + adler-32: 1.3.1 + cfb: 1.2.2 + codepage: 1.15.0 + crc-32: 1.2.2 + ssf: 0.11.2 + wmf: 1.0.2 + word: 0.3.0 + xmlchars@2.2.0: {} xtend@4.0.2: {}