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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user