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