Files
Horux360/apps/web/components/cfdi/cfdi-invoice.tsx
Consultoria AS 8c3fb76406 feat(cfdi): redesign invoice viewer with professional layout
- Add gradient header with emisor info and prominent serie/folio
- Improve status badges with pill design
- Add receptor section with left accent border
- Show complete uso CFDI descriptions
- Create card grid for payment method, forma pago, moneda
- Improve conceptos table with zebra striping and SAT keys
- Add elegant totals box with blue footer
- Enhance timbre fiscal section with QR placeholder and SAT URL
- Add update-cfdi-xml.js script for bulk XML import

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-17 06:17:29 +00:00

318 lines
14 KiB
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 formatDateTime = (dateString: string) =>
new Date(dateString).toLocaleString('es-MX', {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
const tipoLabels: Record<string, string> = {
ingreso: 'Ingreso',
egreso: 'Egreso',
traslado: 'Traslado',
pago: 'Pago',
nomina: 'Nómina',
};
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',
};
const usoCfdiLabels: Record<string, string> = {
G01: 'Adquisición de mercancías',
G02: 'Devoluciones, descuentos o bonificaciones',
G03: 'Gastos en general',
I01: 'Construcciones',
I02: 'Mobilario y equipo de oficina',
I03: 'Equipo de transporte',
I04: 'Equipo de cómputo',
I05: 'Dados, troqueles, moldes',
I06: 'Comunicaciones telefónicas',
I07: 'Comunicaciones satelitales',
I08: 'Otra maquinaria y equipo',
D01: 'Honorarios médicos',
D02: 'Gastos médicos por incapacidad',
D03: 'Gastos funerales',
D04: 'Donativos',
D05: 'Intereses por créditos hipotecarios',
D06: 'Aportaciones voluntarias SAR',
D07: 'Primas por seguros de gastos médicos',
D08: 'Gastos de transportación escolar',
D09: 'Depósitos en cuentas para el ahorro',
D10: 'Pagos por servicios educativos',
P01: 'Por definir',
S01: 'Sin efectos fiscales',
CP01: 'Pagos',
CN01: 'Nómina',
};
export const CfdiInvoice = forwardRef<HTMLDivElement, CfdiInvoiceProps>(
({ cfdi, conceptos }, ref) => {
return (
<div
ref={ref}
className="bg-white text-gray-800 max-w-[850px] mx-auto text-sm shadow-lg"
style={{ fontFamily: 'Segoe UI, Roboto, Arial, sans-serif' }}
>
{/* Header con gradiente */}
<div className="bg-gradient-to-r from-blue-700 to-blue-900 text-white p-6">
<div className="flex justify-between items-start">
<div>
<h2 className="text-lg font-semibold opacity-90">Emisor</h2>
<p className="text-xl font-bold mt-1">{cfdi.nombreEmisor}</p>
<p className="text-blue-200 text-sm mt-1">RFC: {cfdi.rfcEmisor}</p>
</div>
<div className="text-right">
<div className="flex items-center justify-end gap-3 mb-2">
<span
className={`px-3 py-1 text-xs font-bold rounded-full ${
cfdi.estado === 'vigente'
? 'bg-green-400 text-green-900'
: 'bg-red-400 text-red-900'
}`}
>
{cfdi.estado === 'vigente' ? 'VIGENTE' : 'CANCELADO'}
</span>
<span className="px-3 py-1 text-xs font-bold rounded-full bg-white/20">
{tipoLabels[cfdi.tipo] || cfdi.tipo}
</span>
</div>
<div className="text-3xl font-bold tracking-tight">
{cfdi.serie && <span className="text-blue-300">{cfdi.serie}-</span>}
{cfdi.folio || 'S/N'}
</div>
<p className="text-blue-200 text-sm mt-1">{formatDate(cfdi.fechaEmision)}</p>
</div>
</div>
</div>
<div className="p-6">
{/* Receptor */}
<div className="bg-gray-50 rounded-lg p-4 mb-5 border-l-4 border-blue-600">
<div className="flex items-start justify-between">
<div>
<p className="text-xs text-gray-500 uppercase tracking-wide font-medium">Receptor</p>
<p className="text-lg font-semibold text-gray-800 mt-1">{cfdi.nombreReceptor}</p>
<p className="text-gray-600 text-sm">RFC: {cfdi.rfcReceptor}</p>
</div>
{cfdi.usoCfdi && (
<div className="text-right">
<p className="text-xs text-gray-500 uppercase tracking-wide font-medium">Uso CFDI</p>
<p className="text-sm font-medium text-gray-700 mt-1">
{cfdi.usoCfdi} - {usoCfdiLabels[cfdi.usoCfdi] || ''}
</p>
</div>
)}
</div>
</div>
{/* Datos del Comprobante */}
<div className="grid grid-cols-4 gap-3 mb-5">
<div className="bg-gray-50 rounded-lg p-3 text-center">
<p className="text-xs text-gray-500 uppercase tracking-wide">Método Pago</p>
<p className="text-sm font-semibold text-gray-800 mt-1">
{cfdi.metodoPago || '-'}
</p>
<p className="text-xs text-gray-500">
{cfdi.metodoPago ? metodoPagoLabels[cfdi.metodoPago] || '' : ''}
</p>
</div>
<div className="bg-gray-50 rounded-lg p-3 text-center">
<p className="text-xs text-gray-500 uppercase tracking-wide">Forma Pago</p>
<p className="text-sm font-semibold text-gray-800 mt-1">
{cfdi.formaPago || '-'}
</p>
<p className="text-xs text-gray-500">
{cfdi.formaPago ? formaPagoLabels[cfdi.formaPago] || '' : ''}
</p>
</div>
<div className="bg-gray-50 rounded-lg p-3 text-center">
<p className="text-xs text-gray-500 uppercase tracking-wide">Moneda</p>
<p className="text-sm font-semibold text-gray-800 mt-1">{cfdi.moneda || 'MXN'}</p>
{cfdi.tipoCambio && cfdi.tipoCambio !== 1 && (
<p className="text-xs text-gray-500">TC: {cfdi.tipoCambio}</p>
)}
</div>
<div className="bg-gray-50 rounded-lg p-3 text-center">
<p className="text-xs text-gray-500 uppercase tracking-wide">Versión</p>
<p className="text-sm font-semibold text-gray-800 mt-1">CFDI 4.0</p>
</div>
</div>
{/* Conceptos */}
{conceptos && conceptos.length > 0 && (
<div className="mb-5">
<h3 className="text-xs text-gray-500 uppercase tracking-wide font-medium mb-2 flex items-center gap-2">
<span className="w-1 h-4 bg-blue-600 rounded-full"></span>
Conceptos
</h3>
<div className="border border-gray-200 rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="bg-gray-100">
<th className="text-left py-3 px-4 font-semibold text-gray-700">Descripción</th>
<th className="text-center py-3 px-3 font-semibold text-gray-700 w-20">Cant.</th>
<th className="text-right py-3 px-4 font-semibold text-gray-700 w-32">P. Unitario</th>
<th className="text-right py-3 px-4 font-semibold text-gray-700 w-32">Importe</th>
</tr>
</thead>
<tbody>
{conceptos.map((concepto, idx) => (
<tr
key={idx}
className={`border-t border-gray-100 ${idx % 2 === 1 ? 'bg-gray-50/50' : ''}`}
>
<td className="py-3 px-4">
<p className="text-gray-800">{concepto.descripcion}</p>
{concepto.claveProdServ && (
<p className="text-xs text-gray-400 mt-0.5">
Clave: {concepto.claveProdServ}
{concepto.claveUnidad && ` | Unidad: ${concepto.claveUnidad}`}
</p>
)}
</td>
<td className="text-center py-3 px-3 text-gray-700">{concepto.cantidad}</td>
<td className="text-right py-3 px-4 text-gray-700">
{formatCurrency(concepto.valorUnitario)}
</td>
<td className="text-right py-3 px-4 font-medium text-gray-800">
{formatCurrency(concepto.importe)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Totales */}
<div className="flex justify-end mb-5">
<div className="w-80 bg-gray-50 rounded-lg overflow-hidden">
<div className="divide-y divide-gray-200">
<div className="flex justify-between py-2.5 px-4">
<span className="text-gray-600">Subtotal</span>
<span className="font-medium">{formatCurrency(cfdi.subtotal)}</span>
</div>
{cfdi.descuento > 0 && (
<div className="flex justify-between py-2.5 px-4">
<span className="text-gray-600">Descuento</span>
<span className="font-medium text-red-600">-{formatCurrency(cfdi.descuento)}</span>
</div>
)}
{cfdi.iva > 0 && (
<div className="flex justify-between py-2.5 px-4">
<span className="text-gray-600">IVA (16%)</span>
<span className="font-medium">{formatCurrency(cfdi.iva)}</span>
</div>
)}
{cfdi.ivaRetenido > 0 && (
<div className="flex justify-between py-2.5 px-4">
<span className="text-gray-600">IVA Retenido</span>
<span className="font-medium text-red-600">-{formatCurrency(cfdi.ivaRetenido)}</span>
</div>
)}
{cfdi.isrRetenido > 0 && (
<div className="flex justify-between py-2.5 px-4">
<span className="text-gray-600">ISR Retenido</span>
<span className="font-medium text-red-600">-{formatCurrency(cfdi.isrRetenido)}</span>
</div>
)}
</div>
<div className="bg-blue-700 text-white py-3 px-4 flex justify-between items-center">
<span className="font-semibold">TOTAL</span>
<span className="text-xl font-bold">{formatCurrency(cfdi.total)}</span>
</div>
</div>
</div>
{/* Timbre Fiscal Digital */}
<div className="bg-gradient-to-r from-gray-100 to-gray-50 rounded-lg p-4 border border-gray-200">
<div className="flex gap-4">
{/* QR Placeholder */}
<div className="w-24 h-24 bg-white border-2 border-gray-300 rounded-lg flex items-center justify-center flex-shrink-0">
<div className="text-center">
<svg className="w-12 h-12 text-gray-400 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M12 4v1m6 11h2m-6 0h-2v4m0-11v3m0 0h.01M12 12h4.01M16 20h2M4 12h4m12 0h.01M5 8h2a1 1 0 001-1V5a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1zm12 0h2a1 1 0 001-1V5a1 1 0 00-1-1h-2a1 1 0 00-1 1v2a1 1 0 001 1zM5 20h2a1 1 0 001-1v-2a1 1 0 00-1-1H5a1 1 0 00-1 1v2a1 1 0 001 1z" />
</svg>
<span className="text-[10px] text-gray-400 mt-1 block">QR</span>
</div>
</div>
{/* Info del Timbre */}
<div className="flex-1 min-w-0">
<h3 className="text-xs text-gray-500 uppercase tracking-wide font-semibold mb-2 flex items-center gap-2">
<svg className="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
</svg>
Timbre Fiscal Digital
</h3>
<div className="space-y-1.5">
<div>
<span className="text-xs text-gray-500">UUID: </span>
<span className="text-xs font-mono text-blue-700 font-medium">{cfdi.uuidFiscal}</span>
</div>
<div>
<span className="text-xs text-gray-500">Fecha de Timbrado: </span>
<span className="text-xs font-medium text-gray-700">{formatDateTime(cfdi.fechaTimbrado)}</span>
</div>
</div>
</div>
</div>
{/* Leyenda */}
<p className="text-[10px] text-gray-400 mt-3 text-center border-t border-gray-200 pt-2">
Este documento es una representación impresa de un CFDI Verificable en: https://verificacfdi.facturaelectronica.sat.gob.mx
</p>
</div>
</div>
</div>
);
}
);
CfdiInvoice.displayName = 'CfdiInvoice';