- 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>
318 lines
14 KiB
TypeScript
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';
|