docs: add CFDI viewer implementation plan
Detailed step-by-step implementation plan for: - PDF-like invoice visualization - PDF download via html2pdf.js - XML download endpoint - Modal integration in CFDI page Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
816
docs/plans/2026-02-17-cfdi-viewer-implementation.md
Normal file
816
docs/plans/2026-02-17-cfdi-viewer-implementation.md
Normal file
@@ -0,0 +1,816 @@
|
||||
# CFDI Viewer Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add PDF-like invoice visualization for CFDIs with PDF and XML download capabilities.
|
||||
|
||||
**Architecture:** React modal component with invoice renderer. Backend returns XML via new endpoint. html2pdf.js generates PDF client-side from rendered HTML.
|
||||
|
||||
**Tech Stack:** React, TypeScript, html2pdf.js, Tailwind CSS
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Install html2pdf.js Dependency
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/package.json`
|
||||
|
||||
**Step 1: Install the dependency**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd /root/Horux/apps/web && pnpm add html2pdf.js
|
||||
```
|
||||
|
||||
**Step 2: Verify installation**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
grep html2pdf apps/web/package.json
|
||||
```
|
||||
|
||||
Expected: `"html2pdf.js": "^0.10.x"`
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/package.json apps/web/pnpm-lock.yaml
|
||||
git commit -m "chore: add html2pdf.js for PDF generation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Add xmlOriginal to Cfdi Type
|
||||
|
||||
**Files:**
|
||||
- Modify: `packages/shared/src/types/cfdi.ts:4-31`
|
||||
|
||||
**Step 1: Add xmlOriginal field to Cfdi interface**
|
||||
|
||||
In `packages/shared/src/types/cfdi.ts`, add after line 29 (`pdfUrl: string | null;`):
|
||||
|
||||
```typescript
|
||||
xmlOriginal: string | null;
|
||||
```
|
||||
|
||||
**Step 2: Verify types compile**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd /root/Horux && pnpm build --filter=@horux/shared
|
||||
```
|
||||
|
||||
Expected: Build succeeds
|
||||
|
||||
**Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/shared/src/types/cfdi.ts
|
||||
git commit -m "feat(types): add xmlOriginal field to Cfdi interface"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Update Backend Service to Return XML
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/api/src/services/cfdi.service.ts:77-95`
|
||||
|
||||
**Step 1: Update getCfdiById to include xml_original**
|
||||
|
||||
Replace the `getCfdiById` function:
|
||||
|
||||
```typescript
|
||||
export async function getCfdiById(schema: string, id: string): Promise<Cfdi | null> {
|
||||
const result = await prisma.$queryRawUnsafe<Cfdi[]>(`
|
||||
SELECT
|
||||
id, uuid_fiscal as "uuidFiscal", tipo, serie, folio,
|
||||
fecha_emision as "fechaEmision", fecha_timbrado as "fechaTimbrado",
|
||||
rfc_emisor as "rfcEmisor", nombre_emisor as "nombreEmisor",
|
||||
rfc_receptor as "rfcReceptor", nombre_receptor as "nombreReceptor",
|
||||
subtotal, descuento, iva, isr_retenido as "isrRetenido",
|
||||
iva_retenido as "ivaRetenido", total, moneda,
|
||||
tipo_cambio as "tipoCambio", metodo_pago as "metodoPago",
|
||||
forma_pago as "formaPago", uso_cfdi as "usoCfdi",
|
||||
estado, xml_url as "xmlUrl", pdf_url as "pdfUrl",
|
||||
xml_original as "xmlOriginal",
|
||||
created_at as "createdAt"
|
||||
FROM "${schema}".cfdis
|
||||
WHERE id = $1
|
||||
`, id);
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Add getXmlById function**
|
||||
|
||||
Add after `getCfdiById`:
|
||||
|
||||
```typescript
|
||||
export async function getXmlById(schema: string, id: string): Promise<string | null> {
|
||||
const result = await prisma.$queryRawUnsafe<[{ xml_original: string | null }]>(`
|
||||
SELECT xml_original FROM "${schema}".cfdis WHERE id = $1
|
||||
`, id);
|
||||
|
||||
return result[0]?.xml_original || null;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Verify API compiles**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd /root/Horux/apps/api && pnpm build
|
||||
```
|
||||
|
||||
Expected: Build succeeds
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/api/src/services/cfdi.service.ts
|
||||
git commit -m "feat(api): add xmlOriginal to getCfdiById and add getXmlById"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Add XML Download Endpoint
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/api/src/controllers/cfdi.controller.ts`
|
||||
- Modify: `apps/api/src/routes/cfdi.routes.ts`
|
||||
|
||||
**Step 1: Add getXml controller function**
|
||||
|
||||
Add to `apps/api/src/controllers/cfdi.controller.ts` after `getCfdiById`:
|
||||
|
||||
```typescript
|
||||
export async function getXml(req: Request, res: Response, next: NextFunction) {
|
||||
try {
|
||||
if (!req.tenantSchema) {
|
||||
return next(new AppError(400, 'Schema no configurado'));
|
||||
}
|
||||
|
||||
const xml = await cfdiService.getXmlById(req.tenantSchema, String(req.params.id));
|
||||
|
||||
if (!xml) {
|
||||
return next(new AppError(404, 'XML no encontrado para este CFDI'));
|
||||
}
|
||||
|
||||
res.set('Content-Type', 'application/xml');
|
||||
res.set('Content-Disposition', `attachment; filename="cfdi-${req.params.id}.xml"`);
|
||||
res.send(xml);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Add route for XML download**
|
||||
|
||||
In `apps/api/src/routes/cfdi.routes.ts`, add after line 13 (`router.get('/:id', ...)`):
|
||||
|
||||
```typescript
|
||||
router.get('/:id/xml', cfdiController.getXml);
|
||||
```
|
||||
|
||||
**Step 3: Verify API compiles**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd /root/Horux/apps/api && pnpm build
|
||||
```
|
||||
|
||||
Expected: Build succeeds
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/api/src/controllers/cfdi.controller.ts apps/api/src/routes/cfdi.routes.ts
|
||||
git commit -m "feat(api): add GET /cfdi/:id/xml endpoint"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Add API Client Function for XML Download
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/lib/api/cfdi.ts`
|
||||
|
||||
**Step 1: Add getCfdiXml function**
|
||||
|
||||
Add at the end of `apps/web/lib/api/cfdi.ts`:
|
||||
|
||||
```typescript
|
||||
export async function getCfdiXml(id: string): Promise<string> {
|
||||
const response = await apiClient.get<string>(`/cfdi/${id}/xml`, {
|
||||
responseType: 'text'
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/lib/api/cfdi.ts
|
||||
git commit -m "feat(web): add getCfdiXml API function"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Create CfdiInvoice Component
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/components/cfdi/cfdi-invoice.tsx`
|
||||
|
||||
**Step 1: Create the component**
|
||||
|
||||
Create `apps/web/components/cfdi/cfdi-invoice.tsx`:
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { forwardRef } from 'react';
|
||||
import type { Cfdi } from '@horux/shared';
|
||||
|
||||
interface CfdiConcepto {
|
||||
descripcion: string;
|
||||
cantidad: number;
|
||||
valorUnitario: number;
|
||||
importe: number;
|
||||
claveUnidad?: string;
|
||||
claveProdServ?: string;
|
||||
}
|
||||
|
||||
interface CfdiInvoiceProps {
|
||||
cfdi: Cfdi;
|
||||
conceptos?: CfdiConcepto[];
|
||||
}
|
||||
|
||||
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: 'long',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
const tipoLabels: Record<string, string> = {
|
||||
ingreso: 'Ingreso',
|
||||
egreso: 'Egreso',
|
||||
traslado: 'Traslado',
|
||||
pago: 'Pago',
|
||||
nomina: 'Nomina',
|
||||
};
|
||||
|
||||
const formaPagoLabels: Record<string, string> = {
|
||||
'01': 'Efectivo',
|
||||
'02': 'Cheque nominativo',
|
||||
'03': 'Transferencia electrónica',
|
||||
'04': 'Tarjeta de crédito',
|
||||
'28': 'Tarjeta de débito',
|
||||
'99': 'Por definir',
|
||||
};
|
||||
|
||||
const metodoPagoLabels: Record<string, string> = {
|
||||
PUE: 'Pago en una sola exhibición',
|
||||
PPD: 'Pago en parcialidades o diferido',
|
||||
};
|
||||
|
||||
export const CfdiInvoice = forwardRef<HTMLDivElement, CfdiInvoiceProps>(
|
||||
({ cfdi, conceptos }, ref) => {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="bg-white text-black p-8 max-w-[800px] mx-auto text-sm"
|
||||
style={{ fontFamily: 'Arial, sans-serif' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-start border-b-2 border-gray-800 pb-4 mb-4">
|
||||
<div className="w-32 h-20 bg-gray-200 flex items-center justify-center text-gray-500 text-xs">
|
||||
[LOGO]
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<h1 className="text-2xl font-bold text-gray-800">FACTURA</h1>
|
||||
<p className="text-gray-600">
|
||||
{cfdi.serie && `Serie: ${cfdi.serie} `}
|
||||
{cfdi.folio && `Folio: ${cfdi.folio}`}
|
||||
</p>
|
||||
<p className="text-gray-600">Fecha: {formatDate(cfdi.fechaEmision)}</p>
|
||||
<span
|
||||
className={`inline-block px-2 py-1 text-xs font-semibold rounded mt-1 ${
|
||||
cfdi.estado === 'vigente'
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}
|
||||
>
|
||||
{cfdi.estado === 'vigente' ? 'VIGENTE' : 'CANCELADO'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Emisor / Receptor */}
|
||||
<div className="grid grid-cols-2 gap-6 mb-6">
|
||||
<div className="border border-gray-300 p-4 rounded">
|
||||
<h3 className="font-bold text-gray-700 border-b border-gray-200 pb-1 mb-2">
|
||||
EMISOR
|
||||
</h3>
|
||||
<p className="font-semibold">{cfdi.nombreEmisor}</p>
|
||||
<p className="text-gray-600">RFC: {cfdi.rfcEmisor}</p>
|
||||
</div>
|
||||
<div className="border border-gray-300 p-4 rounded">
|
||||
<h3 className="font-bold text-gray-700 border-b border-gray-200 pb-1 mb-2">
|
||||
RECEPTOR
|
||||
</h3>
|
||||
<p className="font-semibold">{cfdi.nombreReceptor}</p>
|
||||
<p className="text-gray-600">RFC: {cfdi.rfcReceptor}</p>
|
||||
{cfdi.usoCfdi && (
|
||||
<p className="text-gray-600">Uso CFDI: {cfdi.usoCfdi}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Datos del Comprobante */}
|
||||
<div className="border border-gray-300 p-4 rounded mb-6">
|
||||
<h3 className="font-bold text-gray-700 border-b border-gray-200 pb-1 mb-2">
|
||||
DATOS DEL COMPROBANTE
|
||||
</h3>
|
||||
<div className="grid grid-cols-4 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">Tipo:</span>
|
||||
<p className="font-medium">{tipoLabels[cfdi.tipo] || cfdi.tipo}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Método de Pago:</span>
|
||||
<p className="font-medium">
|
||||
{cfdi.metodoPago ? metodoPagoLabels[cfdi.metodoPago] || cfdi.metodoPago : '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Forma de Pago:</span>
|
||||
<p className="font-medium">
|
||||
{cfdi.formaPago ? formaPagoLabels[cfdi.formaPago] || cfdi.formaPago : '-'}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Moneda:</span>
|
||||
<p className="font-medium">
|
||||
{cfdi.moneda}
|
||||
{cfdi.tipoCambio !== 1 && ` (TC: ${cfdi.tipoCambio})`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Conceptos */}
|
||||
{conceptos && conceptos.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h3 className="font-bold text-gray-700 border-b border-gray-200 pb-1 mb-2">
|
||||
CONCEPTOS
|
||||
</h3>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="text-left p-2 border">Descripción</th>
|
||||
<th className="text-center p-2 border w-20">Cant.</th>
|
||||
<th className="text-right p-2 border w-28">P. Unit.</th>
|
||||
<th className="text-right p-2 border w-28">Importe</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{conceptos.map((concepto, idx) => (
|
||||
<tr key={idx} className="border-b">
|
||||
<td className="p-2 border">{concepto.descripcion}</td>
|
||||
<td className="text-center p-2 border">{concepto.cantidad}</td>
|
||||
<td className="text-right p-2 border">
|
||||
{formatCurrency(concepto.valorUnitario)}
|
||||
</td>
|
||||
<td className="text-right p-2 border">
|
||||
{formatCurrency(concepto.importe)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Totales */}
|
||||
<div className="flex justify-end mb-6">
|
||||
<div className="w-64">
|
||||
<div className="flex justify-between py-1 border-b">
|
||||
<span className="text-gray-600">Subtotal:</span>
|
||||
<span>{formatCurrency(cfdi.subtotal)}</span>
|
||||
</div>
|
||||
{cfdi.descuento > 0 && (
|
||||
<div className="flex justify-between py-1 border-b">
|
||||
<span className="text-gray-600">Descuento:</span>
|
||||
<span className="text-red-600">-{formatCurrency(cfdi.descuento)}</span>
|
||||
</div>
|
||||
)}
|
||||
{cfdi.iva > 0 && (
|
||||
<div className="flex justify-between py-1 border-b">
|
||||
<span className="text-gray-600">IVA (16%):</span>
|
||||
<span>{formatCurrency(cfdi.iva)}</span>
|
||||
</div>
|
||||
)}
|
||||
{cfdi.ivaRetenido > 0 && (
|
||||
<div className="flex justify-between py-1 border-b">
|
||||
<span className="text-gray-600">IVA Retenido:</span>
|
||||
<span className="text-red-600">-{formatCurrency(cfdi.ivaRetenido)}</span>
|
||||
</div>
|
||||
)}
|
||||
{cfdi.isrRetenido > 0 && (
|
||||
<div className="flex justify-between py-1 border-b">
|
||||
<span className="text-gray-600">ISR Retenido:</span>
|
||||
<span className="text-red-600">-{formatCurrency(cfdi.isrRetenido)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between py-2 font-bold text-lg border-t-2 border-gray-800 mt-1">
|
||||
<span>TOTAL:</span>
|
||||
<span>{formatCurrency(cfdi.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Timbre Fiscal */}
|
||||
<div className="border-t-2 border-gray-800 pt-4">
|
||||
<h3 className="font-bold text-gray-700 mb-2">TIMBRE FISCAL DIGITAL</h3>
|
||||
<div className="grid grid-cols-2 gap-4 text-xs">
|
||||
<div>
|
||||
<p className="text-gray-500">UUID:</p>
|
||||
<p className="font-mono break-all">{cfdi.uuidFiscal}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-500">Fecha de Timbrado:</p>
|
||||
<p>{cfdi.fechaTimbrado}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CfdiInvoice.displayName = 'CfdiInvoice';
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/components/cfdi/cfdi-invoice.tsx
|
||||
git commit -m "feat(web): add CfdiInvoice component for PDF-like rendering"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Create CfdiViewerModal Component
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/web/components/cfdi/cfdi-viewer-modal.tsx`
|
||||
|
||||
**Step 1: Create the modal component**
|
||||
|
||||
Create `apps/web/components/cfdi/cfdi-viewer-modal.tsx`:
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import type { Cfdi } from '@horux/shared';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CfdiInvoice } from './cfdi-invoice';
|
||||
import { getCfdiXml } from '@/lib/api/cfdi';
|
||||
import { Download, FileText, X, Loader2 } from 'lucide-react';
|
||||
|
||||
interface CfdiConcepto {
|
||||
descripcion: string;
|
||||
cantidad: number;
|
||||
valorUnitario: number;
|
||||
importe: number;
|
||||
}
|
||||
|
||||
interface CfdiViewerModalProps {
|
||||
cfdi: Cfdi | null;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function parseConceptosFromXml(xmlString: string): CfdiConcepto[] {
|
||||
try {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(xmlString, 'text/xml');
|
||||
const conceptos: CfdiConcepto[] = [];
|
||||
|
||||
// Find all Concepto elements
|
||||
const elements = doc.getElementsByTagName('*');
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
if (elements[i].localName === 'Concepto') {
|
||||
const el = elements[i];
|
||||
conceptos.push({
|
||||
descripcion: el.getAttribute('Descripcion') || '',
|
||||
cantidad: parseFloat(el.getAttribute('Cantidad') || '1'),
|
||||
valorUnitario: parseFloat(el.getAttribute('ValorUnitario') || '0'),
|
||||
importe: parseFloat(el.getAttribute('Importe') || '0'),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return conceptos;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function CfdiViewerModal({ cfdi, open, onClose }: CfdiViewerModalProps) {
|
||||
const invoiceRef = useRef<HTMLDivElement>(null);
|
||||
const [conceptos, setConceptos] = useState<CfdiConcepto[]>([]);
|
||||
const [downloading, setDownloading] = useState<'pdf' | 'xml' | null>(null);
|
||||
const [xmlContent, setXmlContent] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (cfdi?.xmlOriginal) {
|
||||
setXmlContent(cfdi.xmlOriginal);
|
||||
setConceptos(parseConceptosFromXml(cfdi.xmlOriginal));
|
||||
} else {
|
||||
setXmlContent(null);
|
||||
setConceptos([]);
|
||||
}
|
||||
}, [cfdi]);
|
||||
|
||||
const handleDownloadPdf = async () => {
|
||||
if (!invoiceRef.current || !cfdi) return;
|
||||
|
||||
setDownloading('pdf');
|
||||
try {
|
||||
const html2pdf = (await import('html2pdf.js')).default;
|
||||
|
||||
const opt = {
|
||||
margin: 10,
|
||||
filename: `factura-${cfdi.uuidFiscal.substring(0, 8)}.pdf`,
|
||||
image: { type: 'jpeg', quality: 0.98 },
|
||||
html2canvas: { scale: 2, useCORS: true },
|
||||
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' },
|
||||
};
|
||||
|
||||
await html2pdf().set(opt).from(invoiceRef.current).save();
|
||||
} catch (error) {
|
||||
console.error('Error generating PDF:', error);
|
||||
alert('Error al generar el PDF');
|
||||
} finally {
|
||||
setDownloading(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadXml = async () => {
|
||||
if (!cfdi) return;
|
||||
|
||||
setDownloading('xml');
|
||||
try {
|
||||
let xml = xmlContent;
|
||||
|
||||
if (!xml) {
|
||||
xml = await getCfdiXml(cfdi.id);
|
||||
}
|
||||
|
||||
if (!xml) {
|
||||
alert('No hay XML disponible para este CFDI');
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = new Blob([xml], { type: 'application/xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `cfdi-${cfdi.uuidFiscal.substring(0, 8)}.xml`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (error) {
|
||||
console.error('Error downloading XML:', error);
|
||||
alert('Error al descargar el XML');
|
||||
} finally {
|
||||
setDownloading(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!cfdi) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<DialogTitle>Vista de Factura</DialogTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownloadPdf}
|
||||
disabled={downloading !== null}
|
||||
>
|
||||
{downloading === 'pdf' ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
PDF
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownloadXml}
|
||||
disabled={downloading !== null || !xmlContent}
|
||||
title={!xmlContent ? 'XML no disponible' : 'Descargar XML'}
|
||||
>
|
||||
{downloading === 'xml' ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<FileText className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
XML
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="border rounded-lg overflow-hidden bg-gray-50 p-4">
|
||||
<CfdiInvoice ref={invoiceRef} cfdi={cfdi} conceptos={conceptos} />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/components/cfdi/cfdi-viewer-modal.tsx
|
||||
git commit -m "feat(web): add CfdiViewerModal with PDF and XML download"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Integrate Viewer into CFDI Page
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/web/app/(dashboard)/cfdi/page.tsx`
|
||||
|
||||
**Step 1: Add imports at top of file**
|
||||
|
||||
Add after the existing imports (around line 14):
|
||||
|
||||
```typescript
|
||||
import { Eye } from 'lucide-react';
|
||||
import { CfdiViewerModal } from '@/components/cfdi/cfdi-viewer-modal';
|
||||
import { getCfdiById } from '@/lib/api/cfdi';
|
||||
```
|
||||
|
||||
**Step 2: Add state for viewer modal**
|
||||
|
||||
Inside `CfdiPage` component, after line 255 (`const deleteCfdi = useDeleteCfdi();`), add:
|
||||
|
||||
```typescript
|
||||
const [viewingCfdi, setViewingCfdi] = useState<Cfdi | null>(null);
|
||||
const [loadingCfdi, setLoadingCfdi] = useState<string | null>(null);
|
||||
|
||||
const handleViewCfdi = async (id: string) => {
|
||||
setLoadingCfdi(id);
|
||||
try {
|
||||
const cfdi = await getCfdiById(id);
|
||||
setViewingCfdi(cfdi);
|
||||
} catch (error) {
|
||||
console.error('Error loading CFDI:', error);
|
||||
alert('Error al cargar el CFDI');
|
||||
} finally {
|
||||
setLoadingCfdi(null);
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
**Step 3: Add import for Cfdi type**
|
||||
|
||||
Update the import from `@horux/shared` at line 12 to include `Cfdi`:
|
||||
|
||||
```typescript
|
||||
import type { CfdiFilters, TipoCfdi, Cfdi } from '@horux/shared';
|
||||
```
|
||||
|
||||
**Step 4: Add View button in table**
|
||||
|
||||
In the table header (around line 1070), add a new column header before the delete column:
|
||||
|
||||
```typescript
|
||||
<th className="pb-3 font-medium"></th>
|
||||
```
|
||||
|
||||
In the table body (inside the map, around line 1125), add before the delete button:
|
||||
|
||||
```typescript
|
||||
<td className="py-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleViewCfdi(cfdi.id)}
|
||||
disabled={loadingCfdi === cfdi.id}
|
||||
title="Ver factura"
|
||||
>
|
||||
{loadingCfdi === cfdi.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</td>
|
||||
```
|
||||
|
||||
**Step 5: Add modal component**
|
||||
|
||||
At the end of the return statement, just before the closing `</>`, add:
|
||||
|
||||
```typescript
|
||||
<CfdiViewerModal
|
||||
cfdi={viewingCfdi}
|
||||
open={viewingCfdi !== null}
|
||||
onClose={() => setViewingCfdi(null)}
|
||||
/>
|
||||
```
|
||||
|
||||
**Step 6: Verify build**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd /root/Horux/apps/web && pnpm build
|
||||
```
|
||||
|
||||
Expected: Build succeeds
|
||||
|
||||
**Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/web/app/\(dashboard\)/cfdi/page.tsx
|
||||
git commit -m "feat(web): integrate CFDI viewer modal into CFDI page"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Build and Test
|
||||
|
||||
**Step 1: Build all packages**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
cd /root/Horux && pnpm build
|
||||
```
|
||||
|
||||
Expected: All packages build successfully
|
||||
|
||||
**Step 2: Restart services**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
systemctl restart horux-api horux-web
|
||||
```
|
||||
|
||||
**Step 3: Manual verification**
|
||||
|
||||
1. Open browser to CFDI page
|
||||
2. Click Eye icon on any CFDI row
|
||||
3. Verify modal opens with invoice preview
|
||||
4. Click PDF button - verify PDF downloads
|
||||
5. Click XML button (if XML exists) - verify XML downloads
|
||||
|
||||
**Step 4: Final commit with all changes**
|
||||
|
||||
```bash
|
||||
git add -A
|
||||
git status
|
||||
# If any uncommitted changes:
|
||||
git commit -m "feat: complete CFDI viewer implementation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary of Changes
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `apps/web/package.json` | Added html2pdf.js dependency |
|
||||
| `packages/shared/src/types/cfdi.ts` | Added xmlOriginal field |
|
||||
| `apps/api/src/services/cfdi.service.ts` | Updated getCfdiById, added getXmlById |
|
||||
| `apps/api/src/controllers/cfdi.controller.ts` | Added getXml controller |
|
||||
| `apps/api/src/routes/cfdi.routes.ts` | Added GET /:id/xml route |
|
||||
| `apps/web/lib/api/cfdi.ts` | Added getCfdiXml function |
|
||||
| `apps/web/components/cfdi/cfdi-invoice.tsx` | NEW - Invoice renderer |
|
||||
| `apps/web/components/cfdi/cfdi-viewer-modal.tsx` | NEW - Modal wrapper |
|
||||
| `apps/web/app/(dashboard)/cfdi/page.tsx` | Integrated viewer |
|
||||
Reference in New Issue
Block a user