feat(web): add CfdiInvoice component for PDF-like rendering
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
230
apps/web/components/cfdi/cfdi-invoice.tsx
Normal file
230
apps/web/components/cfdi/cfdi-invoice.tsx
Normal file
@@ -0,0 +1,230 @@
|
||||
'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';
|
||||
Reference in New Issue
Block a user