feat: add Excel export, keyboard shortcuts, and print view for CFDIs

- Add export to Excel button with xlsx library for filtered data
- Add keyboard shortcuts (Esc to close popovers/forms)
- Add print button to invoice viewer modal with optimized print styles

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Consultoria AS
2026-02-17 07:40:11 +00:00
parent 562e23d8bf
commit 2994de4ce0
4 changed files with 235 additions and 13 deletions

View File

@@ -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<CreateCfdiData>(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
</Button>
</div>
{canEdit && (
<div className="flex gap-2">
<Button onClick={() => { setShowForm(true); setShowBulkForm(false); }}>
<Plus className="h-4 w-4 mr-1" />
Agregar
<div className="flex gap-2">
{data && data.data.length > 0 && (
<Button variant="outline" onClick={exportToExcel} disabled={exporting}>
{exporting ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<Download className="h-4 w-4 mr-1" />
)}
Exportar
</Button>
<Button variant="outline" onClick={() => { setShowBulkForm(true); setShowForm(false); }}>
<Upload className="h-4 w-4 mr-1" />
Carga Masiva
</Button>
</div>
)}
)}
{canEdit && (
<>
<Button onClick={() => { setShowForm(true); setShowBulkForm(false); }}>
<Plus className="h-4 w-4 mr-1" />
Agregar
</Button>
<Button variant="outline" onClick={() => { setShowBulkForm(true); setShowForm(false); }}>
<Upload className="h-4 w-4 mr-1" />
Carga Masiva
</Button>
</>
)}
</div>
</div>
</CardContent>
</Card>

View File

@@ -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
</Button>
<Button
variant="outline"
size="sm"
onClick={handlePrint}
disabled={downloading !== null}
title="Imprimir factura"
>
<Printer className="h-4 w-4 mr-1" />
Imprimir
</Button>
</div>
</div>
</DialogHeader>

View File

@@ -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",