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>
This commit is contained in:
Consultoria AS
2026-02-17 06:17:29 +00:00
parent 5ff5629cd8
commit 8c3fb76406
2 changed files with 383 additions and 161 deletions

View File

@@ -30,12 +30,21 @@ const formatDate = (dateString: string) =>
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: 'Nomina',
nomina: 'Nómina',
};
const formaPagoLabels: Record<string, string> = {
@@ -52,174 +61,252 @@ const metodoPagoLabels: Record<string, string> = {
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-black p-8 max-w-[800px] mx-auto text-sm"
style={{ fontFamily: 'Arial, sans-serif' }}
className="bg-white text-gray-800 max-w-[850px] mx-auto text-sm shadow-lg"
style={{ fontFamily: 'Segoe UI, Roboto, 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]
{/* 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 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}`}
</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>
<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>

135
scripts/update-cfdi-xml.js Normal file
View File

@@ -0,0 +1,135 @@
#!/usr/bin/env node
/**
* Script para actualizar CFDIs existentes con su XML original
* Uso: node scripts/update-cfdi-xml.js <directorio> <schema>
* Ejemplo: node scripts/update-cfdi-xml.js /root/xmls tenant_roem691011ez4
*/
const fs = require('fs');
const path = require('path');
const { Pool } = require('pg');
// Configuración de la base de datos
const pool = new Pool({
host: 'localhost',
port: 5432,
database: 'horux360',
user: 'postgres',
password: 'postgres',
});
// Extraer UUID del XML
function extractUuidFromXml(xmlContent) {
// Buscar UUID en TimbreFiscalDigital
const uuidMatch = xmlContent.match(/UUID=["']([A-Fa-f0-9-]{36})["']/i);
return uuidMatch ? uuidMatch[1].toUpperCase() : null;
}
// Procesar archivos en lotes
async function processFiles(directory, schema, batchSize = 500) {
const files = fs.readdirSync(directory).filter(f => f.toLowerCase().endsWith('.xml'));
console.log(`\nEncontrados ${files.length} archivos XML en ${directory}`);
console.log(`Schema: ${schema}`);
console.log(`Tamaño de lote: ${batchSize}\n`);
let updated = 0;
let notFound = 0;
let errors = 0;
let processed = 0;
const startTime = Date.now();
// Procesar en lotes
for (let i = 0; i < files.length; i += batchSize) {
const batch = files.slice(i, i + batchSize);
const updates = [];
// Leer y parsear XMLs del lote
for (const file of batch) {
try {
const filePath = path.join(directory, file);
const xmlContent = fs.readFileSync(filePath, 'utf8');
const uuid = extractUuidFromXml(xmlContent);
if (uuid) {
updates.push({ uuid, xmlContent });
} else {
errors++;
if (errors <= 5) {
console.log(` ⚠ No se encontró UUID en: ${file}`);
}
}
} catch (err) {
errors++;
if (errors <= 5) {
console.log(` ✗ Error leyendo ${file}: ${err.message}`);
}
}
}
// Actualizar base de datos en una transacción
const client = await pool.connect();
try {
await client.query('BEGIN');
for (const { uuid, xmlContent } of updates) {
const result = await client.query(
`UPDATE "${schema}".cfdis SET xml_original = $1 WHERE UPPER(uuid_fiscal) = $2 AND xml_original IS NULL`,
[xmlContent, uuid]
);
if (result.rowCount > 0) {
updated++;
} else {
notFound++;
}
}
await client.query('COMMIT');
} catch (err) {
await client.query('ROLLBACK');
console.error(`Error en lote: ${err.message}`);
errors += batch.length;
} finally {
client.release();
}
processed += batch.length;
// Progreso
const percent = ((processed / files.length) * 100).toFixed(1);
const elapsed = ((Date.now() - startTime) / 1000).toFixed(0);
const rate = (processed / elapsed).toFixed(0);
process.stdout.write(`\r Procesando: ${processed}/${files.length} (${percent}%) - ${updated} actualizados - ${rate} archivos/seg `);
}
const totalTime = ((Date.now() - startTime) / 1000).toFixed(1);
console.log(`\n\n✓ Completado en ${totalTime} segundos`);
console.log(` - Actualizados: ${updated}`);
console.log(` - No encontrados (UUID no existe en BD): ${notFound}`);
console.log(` - Errores: ${errors}`);
await pool.end();
}
// Main
const args = process.argv.slice(2);
if (args.length < 2) {
console.log('Uso: node scripts/update-cfdi-xml.js <directorio> <schema>');
console.log('Ejemplo: node scripts/update-cfdi-xml.js /root/xmls tenant_roem691011ez4');
process.exit(1);
}
const [directory, schema] = args;
if (!fs.existsSync(directory)) {
console.error(`Error: El directorio ${directory} no existe`);
process.exit(1);
}
processFiles(directory, schema).catch(err => {
console.error('Error fatal:', err);
process.exit(1);
});