feat: ecosistema FRASA — apps Cotizador, Alta Clientes, Cobranza y Landing

- Cotizador (/quote): 4 propuestas con formulas validadas del Excel, comparador,
  combobox de unidades, vigencia, PDF ANEXO A
- Alta Clientes (/client): wizard 8 pasos, validacion CURP RENAPO, OCR de INE,
  checklist de expediente, firma digital, 5 PDFs legales
- Cobranza (/pay): planes de enganche, mora, vista cobranza del dia, recibos PDF,
  bitacora, dashboard con filtros
- Landing (/): entrada al ecosistema
- Design system StudioBlank (monocromo, acento #1B4332) en las 4 apps
- 123 tests vitest
This commit is contained in:
2026-08-21 22:27:36 +00:00
commit ecd4edac02
147 changed files with 27345 additions and 0 deletions

View File

@@ -0,0 +1,39 @@
import { jsPDF } from 'jspdf'
import type { AltaCliente } from '../../types'
import { formatoFechaLarga, nombreArchivoPdf } from '../formatters'
import {
AVISO_PRIVACIDAD_TITULO,
AVISO_PRIVACIDAD_PARRAFOS,
avisoPrivacidadParrafoFinal,
} from '../../data/textosLegales'
import { MARGEN, parrafo, titulo, lineaFirma, type DocumentoPdf } from './helpers'
/** Construye el PDF "AVISO DE PRIVACIDAD (SINÓPTICO)" (sin descargarlo) */
export function crearDocumento(alta: AltaCliente): DocumentoPdf {
const { datosGenerales: g, firma } = alta
const doc = new jsPDF()
let y = titulo(doc, MARGEN + 4, AVISO_PRIVACIDAD_TITULO, 13)
y += 2
for (const p of AVISO_PRIVACIDAD_PARRAFOS) {
y = parrafo(doc, y, p)
y += 1
}
y = parrafo(doc, y, avisoPrivacidadParrafoFinal(g.nombreCompleto))
y += 4
const fecha = g.fecha ? new Date(`${g.fecha}T00:00:00`) : new Date()
y = parrafo(doc, y, `Tijuana, Baja California, a ${formatoFechaLarga(fecha)}.`)
y += 2
y = parrafo(doc, y, 'ENTERADO-CONFORME\nAUTORIZO EL USO DE MIS DATOS', { negritas: true })
y += 12
lineaFirma(doc, y, MARGEN + 40, 100, g.nombreCompleto, firma.firmaDataUrl)
return { doc, nombreArchivo: nombreArchivoPdf('aviso_privacidad_firmado', g.nombreCompleto) }
}
/** Genera y descarga el PDF del aviso de privacidad */
export function descargar(alta: AltaCliente): void {
const { doc, nombreArchivo } = crearDocumento(alta)
doc.save(nombreArchivo)
}

View File

@@ -0,0 +1,54 @@
import { jsPDF } from 'jspdf'
import type { AltaCliente } from '../../types'
import { nombreArchivoPdf } from '../formatters'
import {
cartaAntilavadoCuerpo,
CARTA_ANTILAVADO_CIERRE,
CARTA_ANTILAVADO_DESPEDIDA,
} from '../../data/textosLegales'
import { MARGEN, parrafo, lineaFirma, type DocumentoPdf } from './helpers'
/** Construye la carta de no pagos en efectivo (anti-lavado) sin descargarla */
export function crearDocumento(alta: AltaCliente): DocumentoPdf {
const { datosGenerales: g, viviendaCredito: v, firma } = alta
const doc = new jsPDF()
const fecha = g.fecha ? new Date(`${g.fecha}T00:00:00`) : new Date()
const proyecto = v.proyecto === 'OTRO' ? v.proyectoOtro ?? '' : v.proyecto
let y = MARGEN + 4
doc.setFontSize(9)
doc.setFont('helvetica', 'normal')
const dd = String(fecha.getDate()).padStart(2, '0')
const mm = String(fecha.getMonth() + 1).padStart(2, '0')
doc.text(`Tijuana, B.C a ${dd} de ${mm} de ${fecha.getFullYear()}`, MARGEN, y)
y += 10
y = parrafo(doc, y, `Estimado (a) ${g.nombreCompleto}`, { negritas: true, tamano: 9 })
y = parrafo(doc, y, 'P R E S E N T E', { negritas: true, tamano: 9 })
y += 3
y = parrafo(doc, y, cartaAntilavadoCuerpo(proyecto), { tamano: 9 })
y += 2
y = parrafo(doc, y, CARTA_ANTILAVADO_CIERRE, { tamano: 9 })
y += 2
y = parrafo(doc, y, CARTA_ANTILAVADO_DESPEDIDA, { tamano: 9 })
y += 6
y = parrafo(doc, y, 'A T E N T A M E N T E', { negritas: true, tamano: 9 })
y += 12
y = lineaFirma(doc, y, MARGEN + 40, 100, 'Frasa Desarrollos SAPI C.V.')
y = parrafo(doc, y, 'Por medio de su representante legal', { tamano: 9 })
y += 8
y = parrafo(doc, y, 'DE CONFORMIDAD', { negritas: true, tamano: 9 })
y += 12
y = lineaFirma(doc, y, MARGEN + 40, 100, g.nombreCompleto, firma.firmaDataUrl)
parrafo(doc, y, 'En mi carácter de “COMPRADOR”', { tamano: 9 })
return { doc, nombreArchivo: nombreArchivoPdf('carta_antilavado_firmada', g.nombreCompleto) }
}
/** Genera y descarga la carta anti-lavado */
export function descargar(alta: AltaCliente): void {
const { doc, nombreArchivo } = crearDocumento(alta)
doc.save(nombreArchivo)
}

View File

@@ -0,0 +1,116 @@
import { jsPDF } from 'jspdf'
import autoTable from 'jspdf-autotable'
import type { AltaCliente } from '../../types'
import { formatoMoneda, nombreArchivoPdf } from '../formatters'
import {
CHECKLIST_BASICO,
CHECKLIST_ANTES_ESCRITURA,
CHECKLIST_NOTAS,
REQUISITOS_GENERALES,
REQUISITOS_CONTACTO_ENTREGA,
REQUISITOS_CONTACTO_CONTRATO,
} from '../../data/textosLegales'
import { MARGEN, finalY, parrafo, titulo, lineaFirma, numero, type DocumentoPdf } from './helpers'
const vacio = (v: string | undefined | null): string => (v && v.trim() !== '' ? v : '—')
/**
* Construye el PDF "FORMATO DE RECEPCIÓN Y CONTROL DE EXPEDIENTE" +
* página de "REQUISITOS GENERALES" (sin descargarlo)
*/
export function crearDocumento(alta: AltaCliente): DocumentoPdf {
const { datosGenerales: g, viviendaCredito: v, referencias, firma } = alta
const doc = new jsPDF()
const proyecto = v.proyecto === 'OTRO' ? v.proyectoOtro ?? '' : v.proyecto
let y = titulo(doc, MARGEN + 4, 'FORMATO DE RECEPCIÓN Y\nCONTROL DE EXPEDIENTE', 13)
y += 2
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 8, cellPadding: 1.5 },
headStyles: { fillColor: [4, 120, 87] },
body: [
['CLIENTE', g.nombreCompleto],
['PROYECTO', vacio(proyecto)],
['UBICACIÓN / PROTOTIPO', `${vacio(v.ubicacion)} ${vacio(v.modelo)}`.replace('— —', '—')],
['VALOR VIVIENDA', formatoMoneda(numero(v.valorVenta))],
['ENGANCHE', formatoMoneda(numero(v.enganche))],
['FECHA PAGO DE ENGANCHE', vacio(v.fechaPagoEnganche)],
['FIRMA DE CONTRATO', vacio(v.firmaContrato)],
],
})
y = finalY(doc) + 6
// Checklists en dos columnas
doc.setFontSize(9)
doc.setFont('helvetica', 'bold')
doc.text('CHECK LIST EXPEDIENTE BÁSICO:', MARGEN, y)
doc.text('ANTES DE ESCRITURA:', 115, y)
y += 5
doc.setFont('helvetica', 'normal')
doc.setFontSize(8)
const filas = Math.max(CHECKLIST_BASICO.length, CHECKLIST_ANTES_ESCRITURA.length)
for (let i = 0; i < filas; i++) {
if (CHECKLIST_BASICO[i]) doc.text(`[ ] ${CHECKLIST_BASICO[i]}`, MARGEN, y)
if (CHECKLIST_ANTES_ESCRITURA[i]) doc.text(`[ ] ${CHECKLIST_ANTES_ESCRITURA[i]}`, 115, y)
y += 5
}
y += 4
for (const nota of CHECKLIST_NOTAS) {
y = parrafo(doc, y, nota)
y += 1
}
y += 14
// Firmas de recepción del expediente
const yFirmas = y
lineaFirma(doc, yFirmas, MARGEN, 55, 'GLADIS VALENZUELA\nFECHA DE RECEPCIÓN')
lineaFirma(doc, yFirmas, 78, 55, 'DEYSI LORENA ALEMAN FIGUEROA\nCORPORATIVO')
lineaFirma(doc, yFirmas, 142, 55, 'DANIEL ALVARADO LUQUE\nRECIBIÓ')
// Página 2: requisitos generales
doc.addPage()
y = titulo(doc, MARGEN + 4, 'REQUISITOS\nGENERALES', 13)
y = parrafo(doc, y, `NOMBRE: ${g.nombreCompleto}`, { negritas: true, tamano: 9 })
y = parrafo(doc, y, `FECHA: ${vacio(g.fecha)}`, { negritas: true, tamano: 9 })
y += 2
for (const grupo of REQUISITOS_GENERALES) {
y = parrafo(doc, y, grupo.titulo, { negritas: true, tamano: 9 })
for (const item of grupo.items) {
y = parrafo(doc, y, `• ${item}`)
}
y += 2
}
// Referencias en U.S.A. (tabla vacía para llenar a mano)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['NOMBRE', 'TELÉFONO', 'PARENTESCO', 'DIRECCIÓN']],
body: referencias.map((r) => [vacio(r.nombre), vacio(r.telefono), vacio(r.parentesco), vacio(r.direccion)]),
})
y = finalY(doc) + 6
y = parrafo(doc, y, 'ENTREGA DE DOCUMENTOS:', { negritas: true, tamano: 9 })
y = parrafo(doc, y, REQUISITOS_CONTACTO_ENTREGA)
y += 2
y = parrafo(doc, y, 'FIRMA DE CONTRATO:', { negritas: true, tamano: 9 })
y = parrafo(doc, y, REQUISITOS_CONTACTO_CONTRATO)
y += 12
lineaFirma(doc, y, MARGEN + 10, 70, `NOMBRE Y FIRMA DEL CLIENTE\n${g.nombreCompleto}`, firma.firmaDataUrl)
lineaFirma(doc, y, 120, 70, `NOMBRE Y FIRMA DEL ASESOR\n${firma.asesorNombre}`)
return { doc, nombreArchivo: nombreArchivoPdf('checklist_expediente', g.nombreCompleto) }
}
/** Genera y descarga el PDF del checklist de expediente */
export function descargar(alta: AltaCliente): void {
const { doc, nombreArchivo } = crearDocumento(alta)
doc.save(nombreArchivo)
}

View File

@@ -0,0 +1,79 @@
import { jsPDF } from 'jspdf'
export const MARGEN = 14
const ANCHO_UTIL = 210 - MARGEN * 2
const LIMITE_PAGINA = 285
export interface DocumentoPdf {
doc: jsPDF
nombreArchivo: string
}
// jspdf-autotable expone finalY en doc.lastAutoTable en runtime; sus tipos no lo declaran
type DocConTabla = jsPDF & { lastAutoTable: { finalY: number } }
export const finalY = (doc: jsPDF): number => (doc as DocConTabla).lastAutoTable.finalY
export interface OpcionesParrafo {
negritas?: boolean
tamano?: number
alinear?: 'left' | 'center' | 'right'
}
/** Escribe un párrafo con salto de página automático. Devuelve la nueva Y. */
export function parrafo(doc: jsPDF, y: number, texto: string, opciones: OpcionesParrafo = {}): number {
const tamano = opciones.tamano ?? 8
doc.setFontSize(tamano)
doc.setFont('helvetica', opciones.negritas ? 'bold' : 'normal')
const lineas = doc.splitTextToSize(texto, ANCHO_UTIL) as string[]
if (y + lineas.length * tamano * 0.4 > LIMITE_PAGINA) {
doc.addPage()
y = MARGEN
}
const alinear = opciones.alinear ?? 'left'
const x = alinear === 'center' ? 105 : alinear === 'right' ? 210 - MARGEN : MARGEN
doc.text(lineas, x, y, { align: alinear })
return y + lineas.length * tamano * 0.4 + 3
}
/** Título centrado. Devuelve la nueva Y. */
export function titulo(doc: jsPDF, y: number, texto: string, tamano = 12): number {
doc.setFontSize(tamano)
doc.setFont('helvetica', 'bold')
const lineas = doc.splitTextToSize(texto, ANCHO_UTIL) as string[]
doc.text(lineas, 105, y, { align: 'center' })
return y + lineas.length * tamano * 0.45 + 3
}
/**
* Línea de firma con leyenda; si hay imagen de firma se incrusta sobre la línea.
* Devuelve la nueva Y.
*/
export function lineaFirma(
doc: jsPDF,
y: number,
x: number,
ancho: number,
leyenda: string,
firmaDataUrl?: string,
): number {
if (y + 25 > LIMITE_PAGINA) {
doc.addPage()
y = MARGEN + 10
}
if (firmaDataUrl) {
doc.addImage(firmaDataUrl, 'PNG', x + ancho / 2 - 15, y - 14, 30, 14)
}
doc.setLineWidth(0.3)
doc.line(x, y, x + ancho, y)
doc.setFontSize(8)
doc.setFont('helvetica', 'bold')
const lineas = doc.splitTextToSize(leyenda, ancho) as string[]
doc.text(lineas, x + ancho / 2, y + 4, { align: 'center' })
return y + 4 + lineas.length * 8 * 0.4 + 4
}
/** Parsea un texto numérico del formulario; vacío o inválido devuelve 0 */
export function numero(valor: string): number {
const n = Number.parseFloat(valor)
return Number.isFinite(n) ? n : 0
}

View File

@@ -0,0 +1,233 @@
import { jsPDF } from 'jspdf'
import autoTable from 'jspdf-autotable'
import type { AltaCliente } from '../../types'
import { formatoFechaCorta, nombreArchivoPdf } from '../formatters'
import {
PLD_TEXTO_PROTECCION_DATOS,
PLD_TEXTO_DOCUMENTOS_INTEGRAN,
PLD_TEXTO_BENEFICIARIO,
PLD_TEXTO_RESPONSABILIDAD,
PLD_TEXTO_OTRO_FORMATO,
beneficiarioControladorIntro,
BENEFICIARIO_CONTROLADOR_ARTICULO3,
BENEFICIARIO_CONTROLADOR_MANIFIESTO,
BENEFICIARIO_CONTROLADOR_CIERRE,
} from '../../data/textosLegales'
import { MARGEN, finalY, parrafo, titulo, lineaFirma, type DocumentoPdf } from './helpers'
const vacio = (v: string | undefined | null): string => (v && v.trim() !== '' ? v : '')
/**
* Construye el PDF "FORMATO DE IDENTIFICACION DE CLIENTES" (PLD) +
* "FORMATO PARA LA IDENTIFICACIÓN DEL BENEFICIARIO CONTROLADOR" (sin descargarlo)
*/
export function crearDocumento(alta: AltaCliente): DocumentoPdf {
const { datosGenerales: g, viviendaCredito: v, firma } = alta
const df = v.domicilioFiscal
const doc = new jsPDF()
let y = titulo(
doc,
MARGEN + 4,
'FORMATO DE IDENTIFICACION DE CLIENTES\nPERSONA FÍSICA NACIONALIDAD MEXICANA O EXTRANJERA\nCON RESIDENCIA PERMANENTE O TEMPORAL',
)
doc.setFontSize(8)
doc.setFont('helvetica', 'bold')
doc.text(`FECHA: ${formatoFechaCorta(g.fecha)}`, 210 - MARGEN, y, { align: 'right' })
y += 5
// Campos 1 a 11 del formato PLD
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['1 - Nombre y apellidos del cliente']],
body: [[g.nombreCompleto]],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['2 - Fecha de Nacimiento', '3 - Lugar de Nacimiento']],
body: [[formatoFechaCorta(g.fechaNacimiento), vacio(g.lugarNacimiento)]],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['4 - Nacionalizado o Residente', '4 - País']],
body: [[vacio(g.nacionalidad), 'MEXICO']],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['5 - Actividad, Ocupación o Profesión']],
body: [[vacio(g.actividadOcupacion)]],
})
y = finalY(doc)
// 6 - Domicilio particular
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['6 - Domicilio Particular', '']],
body: [
['Calle o Avenida / Dirección', vacio(g.direccion)],
['Colonia o Zona Urbana / Municipio', [vacio(g.delegacionMunicipio)].join(' ')],
['Entidad Federativa / Código Postal / País', `BAJA CALIFORNIA ${vacio(g.codigoPostal)} MEXICO`.trim()],
],
})
y = finalY(doc)
// 6 - Domicilio fiscal (opcional)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['6 - Domicilio Fiscal (Art 29 y 29A CFF)', '']],
body: [
['Calle o Avenida', vacio(df.calle)],
['Número Exterior / Número Interior', `${vacio(df.numExt)} ${vacio(df.numInt)}`.trim()],
['Colonia o Zona Urbana', vacio(df.colonia)],
['Municipio o Demarcación Territorial', vacio(df.municipio)],
['Entidad Federativa / Código Postal / País', `${vacio(df.estado)} ${vacio(df.cp)} ${vacio(df.pais)}`.trim()],
],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['7 - Número Telefónico Casa', '7 - Número Telefónico Celular']],
body: [[vacio(g.telCasa), vacio(g.celular)]],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['8 - Correo Electrónico Válido', '9 - CURP']],
body: [[g.email, g.curp]],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['10 - RFC (SAT)']],
body: [[g.rfc]],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['11 - Datos de la Identificación Oficial Vigente', '']],
body: [
['Nombre en Identificación', g.nombreCompleto],
['Autoridad que la Emite', vacio(v.idAutoridadEmisora)],
['Número de Identificación / Vigencia', `${vacio(v.idNumero)} ${formatoFechaCorta(v.idVigencia ?? '')}`.trim()],
],
})
y = finalY(doc) + 5
// Textos legales PLD
y = parrafo(doc, y, PLD_TEXTO_PROTECCION_DATOS)
y = parrafo(doc, y, 'IV.- DOCUMENTOS QUE SE INTEGRAN AL PRESENTE', { negritas: true })
y = parrafo(doc, y, PLD_TEXTO_DOCUMENTOS_INTEGRAN)
y = parrafo(doc, y, 'Identificación Oficial con Fotografía Comprobante de Domicilio Actual RFC CURP', {
negritas: true,
})
y = parrafo(doc, y, PLD_TEXTO_BENEFICIARIO)
y = parrafo(doc, y, PLD_TEXTO_RESPONSABILIDAD)
y = parrafo(doc, y, PLD_TEXTO_OTRO_FORMATO)
y += 10
y = lineaFirma(doc, y, MARGEN + 40, 100, `NOMBRE Y FIRMA DEL CLIENTE\n${g.nombreCompleto}`, firma.firmaDataUrl)
// Página 2: beneficiario controlador
doc.addPage()
y = titulo(doc, MARGEN + 4, 'FORMATO PARA LA IDENTIFICACIÓN DEL\nBENEFICIARIO CONTROLADOR')
const condominio = vacio(v.manzana) || '___'
y = parrafo(doc, y, beneficiarioControladorIntro(g.nombreCompleto, vacio(v.ubicacion) || '___', condominio))
y = parrafo(doc, y, BENEFICIARIO_CONTROLADOR_ARTICULO3)
y = parrafo(doc, y, BENEFICIARIO_CONTROLADOR_MANIFIESTO)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['Nombre', 'Nacionalidad']],
body: [
[g.nombreCompleto, vacio(g.nacionalidad)],
],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['Fecha Nacimiento', 'Lugar Nacimiento']],
body: [[formatoFechaCorta(g.fechaNacimiento), vacio(g.lugarNacimiento)]],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['Domicilio']],
body: [[`${vacio(g.direccion)} ${vacio(g.codigoPostal)} ${vacio(g.delegacionMunicipio)} BC MEXICO`.trim()]],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['Teléfono Fijo', 'Teléfono Celular']],
body: [[vacio(g.telCasa), vacio(g.celular)]],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['Correo Electrónico', 'Ocupación']],
body: [[g.email, vacio(g.actividadOcupacion)]],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['C.U.R.P', 'R.F.C']],
body: [[g.curp, g.rfc]],
})
y = finalY(doc) + 5
y = parrafo(doc, y, BENEFICIARIO_CONTROLADOR_CIERRE)
y += 10
lineaFirma(doc, y, MARGEN + 40, 100, `NOMBRE Y FIRMA DEL CLIENTE\n${g.nombreCompleto}`, firma.firmaDataUrl)
return { doc, nombreArchivo: nombreArchivoPdf('formato_identificacion_cliente', g.nombreCompleto) }
}
/** Genera y descarga el PDF del formato PLD de identificación */
export function descargar(alta: AltaCliente): void {
const { doc, nombreArchivo } = crearDocumento(alta)
doc.save(nombreArchivo)
}

View File

@@ -0,0 +1,33 @@
import { describe, it, expect } from 'vitest'
import { fixtureAlta } from '../fixture'
import * as perfilCliente from './perfilCliente'
import * as identificacionCliente from './identificacionCliente'
import * as avisoPrivacidad from './avisoPrivacidad'
import * as cartaAntilavado from './cartaAntilavado'
import * as checklistExpediente from './checklistExpediente'
const generadores = [
['perfil_cliente_completo', perfilCliente],
['formato_identificacion_cliente', identificacionCliente],
['aviso_privacidad_firmado', avisoPrivacidad],
['carta_antilavado_firmada', cartaAntilavado],
['checklist_expediente', checklistExpediente],
] as const
describe('generadores de PDF (smoke test con fixture)', () => {
for (const [tipo, modulo] of generadores) {
it(`construye ${tipo} sin lanzar error`, () => {
const { doc, nombreArchivo } = modulo.crearDocumento(fixtureAlta)
expect(doc).toBeDefined()
expect(doc.getNumberOfPages()).toBeGreaterThan(0)
expect(nombreArchivo).toMatch(
new RegExp(`^${tipo}_PASCUAL_MARTIN_TELLEZ_CHIGUINDO_\\d{4}-\\d{2}-\\d{2}\\.pdf$`),
)
})
}
it('el formato PLD y el checklist agregan la segunda página', () => {
expect(identificacionCliente.crearDocumento(fixtureAlta).doc.getNumberOfPages()).toBeGreaterThanOrEqual(2)
expect(checklistExpediente.crearDocumento(fixtureAlta).doc.getNumberOfPages()).toBeGreaterThanOrEqual(2)
})
})

View File

@@ -0,0 +1,245 @@
import { jsPDF } from 'jspdf'
import autoTable from 'jspdf-autotable'
import type { AltaCliente } from '../../types'
import { formatoMoneda, formatoFechaCorta, nombreArchivoPdf } from '../formatters'
import { calcularTotalIngresos, calcularGastosTotal } from '../calculations'
import { TIPOS_CREDITO } from '../../data/constantes'
import { MARGEN, finalY, titulo, lineaFirma, numero, type DocumentoPdf } from './helpers'
const vacio = (v: string | undefined | null): string => (v && v.trim() !== '' ? v : '—')
function seccion(doc: jsPDF, y: number, texto: string): number {
if (y > 260) {
doc.addPage()
y = MARGEN
}
doc.setFontSize(10)
doc.setFont('helvetica', 'bold')
doc.text(texto, MARGEN, y)
return y + 3
}
/** Construye el PDF "PERFIL DEL CLIENTE" (sin descargarlo) */
export function crearDocumento(alta: AltaCliente): DocumentoPdf {
const { datosGenerales: g, empleo: e, economicos: eco, coacreditado, referencias, viviendaCredito: v, firma } = alta
const doc = new jsPDF()
let y = titulo(doc, MARGEN + 4, 'PERFIL DEL CLIENTE', 14)
// PERSONALES
y = seccion(doc, y, 'PERSONALES')
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['FECHA', 'NOMBRE', 'RFC']],
body: [
[formatoFechaCorta(g.fecha), g.nombreCompleto, g.rfc],
],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['FECHA NACIMIENTO', 'LUGAR DE NACIMIENTO', 'NACIONALIDAD']],
body: [[formatoFechaCorta(g.fechaNacimiento), vacio(g.lugarNacimiento), vacio(g.nacionalidad)]],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['CURP', 'CORREO', 'TELÉFONO']],
body: [[g.curp, g.email, g.celular]],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['ESTADO CIVIL', 'RÉGIMEN BIENES', 'PESO', 'ESTATURA']],
body: [[vacio(g.estadoCivil), vacio(g.regimenBienes), vacio(g.peso), vacio(g.estatura)]],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['NIVEL DE ESTUDIOS', 'DIRECCIÓN']],
body: [[vacio(g.nivelEstudios), vacio(g.direccion)]],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['DELEG./MUNICIPIO', 'C.P.', 'ANTIGÜEDAD', 'TIPO DE VIVIENDA', 'TIPO DE COMPRA']],
body: [
[vacio(g.delegacionMunicipio), vacio(g.codigoPostal), vacio(g.antiguedadDomicilio), vacio(g.tipoVivienda), vacio(g.tipoCompra)],
],
})
y = finalY(doc) + 6
// EMPLEO
y = seccion(doc, y, 'EMPLEO')
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['TIPO DE EMPLEO', 'TIPO DE ECONOMÍA', 'COMPAÑÍA', 'ANTIGÜEDAD']],
body: [
[
vacio(e.tipoEmpleo),
vacio(e.tipoEconomia),
vacio(e.compania),
`${vacio(e.antiguedadAnios)} AÑOS ${vacio(e.antiguedadMeses)} MESES`,
],
],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['DIRECCIÓN', 'C.P.', 'PUESTO', 'TELÉFONO', 'EXT.']],
body: [
[
[vacio(e.direccion), vacio(e.colonia), vacio(e.numero)].filter((p) => p !== '—').join(' ') || '—',
vacio(e.codigoPostal),
vacio(e.puesto),
vacio(e.telefono),
vacio(e.ext),
],
],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['INGRESO MENSUAL', 'OTROS INGRESOS', 'TOTAL INGRESOS', 'GASTOS APROXIMADOS', 'DEPENDIENTES ECONÓMICOS']],
body: [
[
formatoMoneda(eco.ingresosMensuales),
formatoMoneda(eco.otrosIngresos),
formatoMoneda(calcularTotalIngresos(eco)),
formatoMoneda(calcularGastosTotal(eco)),
vacio(eco.dependientes.personas),
],
],
})
y = finalY(doc) + 6
// CO-ACREDITADO O CÓNYUGE
if (coacreditado.tieneCoacreditado) {
const cg = coacreditado.generales
const ce = coacreditado.empleo
const ceco = coacreditado.economicos
y = seccion(doc, y, 'CO-ACREDITADO O CÓNYUGE')
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['NOMBRE', 'RFC', 'FECHA NACIMIENTO', 'CURP']],
body: [[cg.nombreCompleto, vacio(cg.rfc), formatoFechaCorta(cg.fechaNacimiento ?? '') || '—', vacio(cg.curp)]],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['CORREO', 'TELÉFONO', 'ESTADO CIVIL', 'COMPAÑÍA', 'PUESTO']],
body: [[vacio(cg.email), vacio(cg.celular), vacio(cg.estadoCivil), vacio(ce.compania), vacio(ce.puesto)]],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['INGRESO MENSUAL', 'OTROS INGRESOS', 'TOTAL INGRESOS', 'GASTOS APROXIMADOS']],
body: [
[
formatoMoneda(ceco.ingresosMensuales),
formatoMoneda(ceco.otrosIngresos),
formatoMoneda(calcularTotalIngresos(ceco)),
formatoMoneda(calcularGastosTotal(ceco)),
],
],
})
y = finalY(doc) + 6
}
// REFERENCIAS PERSONALES
y = seccion(doc, y, 'REFERENCIAS PERSONALES')
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['NOMBRE', 'TELÉFONO', 'PARENTESCO', 'DIRECCIÓN']],
body: referencias.map((r) => [vacio(r.nombre), vacio(r.telefono), vacio(r.parentesco), vacio(r.direccion)]),
})
y = finalY(doc) + 6
// DESARROLLO
y = seccion(doc, y, 'DESARROLLO')
const proyecto = v.proyecto === 'OTRO' ? v.proyectoOtro ?? '' : v.proyecto
const creditos = v.tiposCredito
.map((t) => TIPOS_CREDITO.find((tc) => tc.valor === t)?.etiqueta ?? t)
.join(', ')
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['PROYECTO', 'UBICACIÓN', 'PROTOTIPO']],
body: [
[
[`${vacio(v.manzana)} ${vacio(v.lote)}`.replace(/—/g, '').trim(), proyecto, vacio(v.modelo)].join(' ').trim(),
vacio(v.ubicacion),
vacio(v.modelo),
],
],
})
y = finalY(doc)
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 7, cellPadding: 1 },
headStyles: { fillColor: [4, 120, 87] },
head: [['VALOR VIVIENDA', 'ENGANCHE', 'TIPO DE CRÉDITO', 'MONTO CRÉDITO INFO/FOVI']],
body: [
[
formatoMoneda(numero(v.valorVenta)),
formatoMoneda(numero(v.enganche)),
creditos || '—',
formatoMoneda(numero(v.montoCreditoInfoFovi)),
],
],
})
y = finalY(doc) + 18
// Firmas
lineaFirma(doc, y, MARGEN + 10, 70, g.nombreCompleto, firma.firmaDataUrl)
lineaFirma(doc, y, 120, 70, firma.asesorNombre || 'ASESOR')
return { doc, nombreArchivo: nombreArchivoPdf('perfil_cliente_completo', g.nombreCompleto) }
}
/** Genera y descarga el PDF del perfil del cliente */
export function descargar(alta: AltaCliente): void {
const { doc, nombreArchivo } = crearDocumento(alta)
doc.save(nombreArchivo)
}