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,145 @@
import { useState } from 'react'
import type { DatosGenerales } from '../types'
import { parsearTextoIne } from '../lib/ocrIne'
import type { SugerenciasIne } from '../lib/ocrIne'
type CamposAplicables = Partial<Pick<DatosGenerales, 'curp' | 'nombreCompleto' | 'fechaNacimiento'>>
interface Props {
/** Imagen (JPG/PNG) de la credencial INE ya cargada */
archivo: File
/** Aplica un campo sugerido al formulario de datos generales */
onAplicar: (campos: CamposAplicables) => void
}
type Estado =
| { fase: 'inactivo' }
| { fase: 'procesando'; progreso: number }
| { fase: 'listo'; sugerencias: SugerenciasIne }
| { fase: 'error'; mensaje: string }
interface SugerenciaVisible {
clave: keyof CamposAplicables | 'sexo'
etiqueta: string
valor: string
/** Solo estos campos tienen destino en el formulario */
aplicable: boolean
}
function aVisibles(s: SugerenciasIne): SugerenciaVisible[] {
const v: SugerenciaVisible[] = []
if (s.curp) v.push({ clave: 'curp', etiqueta: 'CURP', valor: s.curp, aplicable: true })
if (s.nombre) v.push({ clave: 'nombreCompleto', etiqueta: 'Nombre', valor: s.nombre, aplicable: true })
if (s.fechaNacimiento)
v.push({ clave: 'fechaNacimiento', etiqueta: 'Fecha de nacimiento', valor: s.fechaNacimiento, aplicable: true })
if (s.sexo) v.push({ clave: 'sexo', etiqueta: 'Sexo', valor: s.sexo === 'H' ? 'Hombre' : 'Mujer', aplicable: false })
return v
}
/**
* OCR del INE con tesseract.js (import dinámico para no inflar el bundle).
* El idioma español se sirve local desde /client/tessdata (base path /client/).
*/
export default function BotonOcrIne({ archivo, onAplicar }: Props) {
const [estado, setEstado] = useState<Estado>({ fase: 'inactivo' })
const [aplicados, setAplicados] = useState<string[]>([])
const leer = async () => {
setEstado({ fase: 'procesando', progreso: 0 })
setAplicados([])
try {
const { createWorker } = await import('tesseract.js')
const worker = await createWorker('spa', undefined, {
langPath: `${import.meta.env.BASE_URL}tessdata`,
gzip: false,
logger: (m: { status: string; progress: number }) => {
if (m.status === 'recognizing text') {
setEstado({ fase: 'procesando', progreso: Math.round(m.progress * 100) })
}
},
})
try {
const { data } = await worker.recognize(archivo)
const sugerencias = parsearTextoIne(data.text)
if (aVisibles(sugerencias).length === 0) {
setEstado({
fase: 'error',
mensaje: 'No se detectaron datos legibles en la imagen. Intenta con una foto más nítida.',
})
} else {
setEstado({ fase: 'listo', sugerencias })
}
} finally {
await worker.terminate()
}
} catch {
setEstado({ fase: 'error', mensaje: 'No fue posible ejecutar el OCR. Verifica tu conexión e inténtalo de nuevo.' })
}
}
const aplicar = (s: SugerenciaVisible) => {
if (!s.aplicable) return
onAplicar({ [s.clave]: s.valor } as CamposAplicables)
setAplicados((prev) => [...prev, s.clave])
}
return (
<div className="border border-neutral-200 bg-white px-4 py-3">
<div className="flex items-center justify-between gap-4">
<p className="text-xs font-bold uppercase tracking-widest text-neutral-500">Lectura automática (OCR)</p>
<button
type="button"
onClick={() => void leer()}
disabled={estado.fase === 'procesando'}
className="shrink-0 border border-neutral-900 bg-transparent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-neutral-900 transition-colors duration-150 hover:bg-neutral-900 hover:text-white disabled:opacity-40"
>
{estado.fase === 'procesando' ? `Leyendo… ${estado.progreso}%` : 'Leer INE con OCR'}
</button>
</div>
{estado.fase === 'procesando' && (
<p className="mt-3 text-xs font-light text-neutral-500" role="status">
Reconociendo texto de la credencial… {estado.progreso}%
</p>
)}
{estado.fase === 'error' && (
<p className="mt-3 border border-red-400 px-3 py-2 text-xs text-red-700" role="alert">
{estado.mensaje}
</p>
)}
{estado.fase === 'listo' && (
<div className="mt-3">
<p className="text-xs font-light text-neutral-500">
Sugerencias detectadas — nada se sobreescribe sin tu confirmación:
</p>
<ul className="mt-2 space-y-2">
{aVisibles(estado.sugerencias).map((s) => (
<li key={s.clave} className="flex items-baseline justify-between gap-4 text-sm">
<span className="font-light text-neutral-900">
<span className="mr-2 text-xs font-bold uppercase tracking-widest text-neutral-500">
{s.etiqueta}
</span>
{s.valor}
</span>
{s.aplicable &&
(aplicados.includes(s.clave) ? (
<span className="text-xs font-bold uppercase tracking-widest text-accent">✓</span>
) : (
<button
type="button"
onClick={() => aplicar(s)}
className="shrink-0 border border-neutral-900 bg-transparent px-3 py-1.5 text-xs font-bold uppercase tracking-widest text-neutral-900 transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
>
Aplicar
</button>
))}
</li>
))}
</ul>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,108 @@
import { useFormContext, get } from 'react-hook-form'
export interface CampoBaseProps {
nombre: string
etiqueta: string
requerido?: boolean
className?: string
}
const inputClase =
'w-full border border-neutral-300 bg-white px-3 py-2.5 text-sm uppercase transition-colors duration-150 focus:border-neutral-900 focus:outline-none'
const labelClase = 'mb-1 block text-xs font-bold uppercase tracking-widest text-neutral-500'
function Etiqueta({ etiqueta, requerido }: { etiqueta: string; requerido?: boolean }) {
return (
<span className={labelClase}>
{etiqueta}
{requerido && <span className="text-red-700"> *</span>}
</span>
)
}
/** Clase del input según haya error de validación */
function claseInput(nombre: string, errors: unknown): string {
return get(errors, nombre) ? `${inputClase} border-red-400` : inputClase
}
export function MensajeError({ nombre }: { nombre: string }) {
const {
formState: { errors },
} = useFormContext()
const error = get(errors, nombre)
if (!error) return null
return <p className="mt-1 text-xs text-red-700">{String(error.message)}</p>
}
interface CampoTextoProps extends CampoBaseProps {
tipo?: string
placeholder?: string
soloLectura?: boolean
}
/** Input de texto conectado a react-hook-form (soporta rutas anidadas con punto) */
export function CampoTexto({ nombre, etiqueta, tipo = 'text', placeholder, requerido, soloLectura, className }: CampoTextoProps) {
const { register, formState: { errors } } = useFormContext()
return (
<label htmlFor={nombre} className={`block ${className ?? ''}`}>
<Etiqueta etiqueta={etiqueta} requerido={requerido} />
<input
id={nombre}
type={tipo}
placeholder={placeholder}
readOnly={soloLectura}
className={`${claseInput(nombre, errors)} ${soloLectura ? 'bg-neutral-50' : ''}`}
{...register(nombre)}
/>
<MensajeError nombre={nombre} />
</label>
)
}
interface CampoNumeroProps extends CampoBaseProps {
paso?: string
}
/** Input numérico; cadena vacía se guarda como 0 */
export function CampoNumero({ nombre, etiqueta, requerido, paso = '0.01', className }: CampoNumeroProps) {
const { register, formState: { errors } } = useFormContext()
return (
<label htmlFor={nombre} className={`block ${className ?? ''}`}>
<Etiqueta etiqueta={etiqueta} requerido={requerido} />
<input
id={nombre}
type="number"
step={paso}
min="0"
className={claseInput(nombre, errors)}
{...register(nombre, {
setValueAs: (v: string) => (v === '' ? 0 : Number(v)),
})}
/>
<MensajeError nombre={nombre} />
</label>
)
}
interface CampoSelectProps extends CampoBaseProps {
opciones: { valor: string; etiqueta: string }[]
}
export function CampoSelect({ nombre, etiqueta, opciones, requerido, className }: CampoSelectProps) {
const { register, formState: { errors } } = useFormContext()
return (
<label htmlFor={nombre} className={`block ${className ?? ''}`}>
<Etiqueta etiqueta={etiqueta} requerido={requerido} />
<select id={nombre} className={claseInput(nombre, errors)} {...register(nombre)}>
<option value="">— Selecciona —</option>
{opciones.map((o) => (
<option key={o.valor} value={o.valor}>
{o.etiqueta}
</option>
))}
</select>
<MensajeError nombre={nombre} />
</label>
)
}

View File

@@ -0,0 +1,34 @@
import { estadoExpediente } from '../lib/expediente'
import type { DocumentoCargado } from '../types'
interface Props {
documentos: DocumentoCargado[]
firmaDataUrl: string
}
/** Panel en vivo del expediente: "Expediente: N/6" con estado plano por item */
export default function ChecklistExpediente({ documentos, firmaDataUrl }: Props) {
const estado = estadoExpediente(documentos, firmaDataUrl)
return (
<section
aria-label="Checklist del expediente"
className="border border-neutral-200 bg-white px-4 py-3"
>
<p className="text-xs font-bold uppercase tracking-widest text-neutral-500">
Expediente: {estado.completos}/{estado.total}
</p>
<ul className="mt-3 space-y-2">
{estado.items.map((item) => (
<li key={item.clave} className="flex items-baseline justify-between gap-4 text-sm">
<span className="font-light text-neutral-900">{item.etiqueta}</span>
{item.completo ? (
<span className="text-xs font-bold uppercase tracking-widest text-accent">✓</span>
) : (
<span className="text-xs uppercase tracking-widest text-neutral-400">Pendiente</span>
)}
</li>
))}
</ul>
</section>
)
}

View File

@@ -0,0 +1,123 @@
import { useCallback, useEffect, useState } from 'react'
import { useDropzone } from 'react-dropzone'
import type { DocumentoCargado, SlotDocumento } from '../types'
import { TAMANO_MAX_DOCUMENTO } from '../data/constantes'
interface Props {
slot: SlotDocumento
etiqueta: string
documento?: DocumentoCargado
onAgregar: (doc: DocumentoCargado) => void
onQuitar: (slot: SlotDocumento) => void
}
function formatoTamano(bytes: number): string {
return bytes >= 1024 * 1024 ? `${(bytes / (1024 * 1024)).toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`
}
/** Miniatura del documento: imagen con object-cover o recuadro plano para PDF */
function Miniatura({ documento, url }: { documento: DocumentoCargado; url: string }) {
const esImagen = documento.tipoMime.startsWith('image/')
return (
<a
href={url}
target="_blank"
rel="noreferrer"
title={`Abrir ${documento.nombre} en una pestaña nueva`}
aria-label={`Abrir ${documento.nombre} en una pestaña nueva`}
className="block h-20 w-20 shrink-0 border border-neutral-200 bg-neutral-50"
>
{esImagen ? (
<img src={url} alt={`Vista previa de ${documento.nombre}`} className="h-full w-full object-cover" />
) : (
<span className="flex h-full w-full flex-col items-center justify-center gap-1">
<span className="text-xs font-bold uppercase tracking-widest text-neutral-900">PDF</span>
<span className="max-w-full truncate px-1 text-[10px] font-light text-neutral-500">{documento.nombre}</span>
</span>
)}
</a>
)
}
/** Slot de carga de un documento (PDF/JPG/PNG, máx 10 MB) con vista previa */
export default function FileUploader({ slot, etiqueta, documento, onAgregar, onQuitar }: Props) {
const [urlObjeto, setUrlObjeto] = useState('')
// URL de objeto para la miniatura y para abrir en pestaña nueva; se revoca al quitar/desmontar
useEffect(() => {
if (!documento) {
setUrlObjeto('')
return
}
const url = URL.createObjectURL(documento.archivo)
setUrlObjeto(url)
return () => URL.revokeObjectURL(url)
}, [documento])
const onDrop = useCallback(
(aceptados: File[]) => {
const archivo = aceptados[0]
if (!archivo) return
onAgregar({ slot, nombre: archivo.name, tamano: archivo.size, tipoMime: archivo.type, archivo })
},
[slot, onAgregar],
)
const { getRootProps, getInputProps, isDragActive, fileRejections } = useDropzone({
onDrop,
multiple: false,
maxSize: TAMANO_MAX_DOCUMENTO,
accept: {
'application/pdf': ['.pdf'],
'image/jpeg': ['.jpg', '.jpeg'],
'image/png': ['.png'],
},
})
if (documento) {
return (
<div className="flex items-center justify-between gap-4 border border-neutral-200 bg-white px-4 py-3">
<div className="flex min-w-0 items-center gap-4">
{urlObjeto && <Miniatura documento={documento} url={urlObjeto} />}
<div className="min-w-0">
<p className="text-xs font-bold uppercase tracking-widest text-neutral-500">{etiqueta}</p>
<p className="mt-1 truncate text-sm text-neutral-900">
{documento.nombre} · {formatoTamano(documento.tamano)}
</p>
</div>
</div>
<button
type="button"
onClick={() => onQuitar(slot)}
className="shrink-0 border border-red-300 bg-white px-3 py-1.5 text-xs font-bold uppercase tracking-widest text-red-700 transition-colors duration-150 hover:bg-red-50"
>
Quitar
</button>
</div>
)
}
return (
<div>
<div
{...getRootProps()}
className={`cursor-pointer border border-dashed border-neutral-300 bg-white px-4 py-6 text-center transition-colors duration-150 ${
isDragActive ? 'border-neutral-900 bg-neutral-50' : 'hover:border-neutral-500'
}`}
>
<input {...getInputProps()} aria-label={`Seleccionar archivo para ${etiqueta}`} />
<p className="text-xs font-bold uppercase tracking-widest text-neutral-500">{etiqueta}</p>
<p className="mt-2 text-xs font-light text-neutral-500">Arrastra el archivo aquí · PDF/JPG/PNG · máx 10 MB</p>
<button
type="button"
className="mt-4 border border-neutral-900 bg-transparent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-neutral-900 transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
>
Seleccionar archivo
</button>
</div>
{fileRejections.length > 0 && (
<p className="mt-1 text-xs text-red-700">Archivo rechazado: formato no permitido o mayor a 10 MB</p>
)}
</div>
)
}

View File

@@ -0,0 +1,18 @@
import type { ReactNode } from 'react'
interface Props {
titulo: string
descripcion?: string
children: ReactNode
}
/** Tarjeta de sección del wizard */
export default function SectionCard({ titulo, descripcion, children }: Props) {
return (
<section className="border border-neutral-200 bg-white p-6">
<h3 className="text-xs font-bold uppercase tracking-widest text-neutral-500">{titulo}</h3>
{descripcion && <p className="mt-2 text-sm font-light text-neutral-500">{descripcion}</p>}
<div className="mt-8 grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">{children}</div>
</section>
)
}

View File

@@ -0,0 +1,48 @@
import { useRef, useEffect } from 'react'
import SignatureCanvas from 'react-signature-canvas'
interface Props {
onFirma: (dataUrl: string) => void
}
/** Pad de firma del cliente; exporta PNG base64 al terminar cada trazo */
export default function SignaturePad({ onFirma }: Props) {
const ref = useRef<SignatureCanvas | null>(null)
useEffect(() => {
// Notifica firma vacía al montar (estado inicial)
onFirma('')
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const limpiar = () => {
ref.current?.clear()
onFirma('')
}
const alTerminar = () => {
const pad = ref.current
if (!pad) return
onFirma(pad.isEmpty() ? '' : pad.getTrimmedCanvas().toDataURL('image/png'))
}
return (
<div>
<div className="border border-neutral-900 bg-white">
<SignatureCanvas
ref={ref}
onEnd={alTerminar}
canvasProps={{ className: 'h-40 w-full', 'aria-label': 'Firma del cliente' }}
penColor="black"
/>
</div>
<button
type="button"
onClick={limpiar}
className="mt-4 border border-neutral-900 bg-transparent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-neutral-900 transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
>
Limpiar firma
</button>
</div>
)
}

View File

@@ -0,0 +1,40 @@
interface Props {
pasos: string[]
pasoActual: number
onIrAPaso: (paso: number) => void
}
/** Stepper de progreso del wizard; permite volver a pasos ya visitados */
export default function Stepper({ pasos, pasoActual, onIrAPaso }: Props) {
return (
<nav aria-label="Progreso">
<ol className="flex flex-wrap items-baseline gap-x-6 gap-y-3 border-b border-neutral-200 pb-4">
{pasos.map((nombre, i) => {
const activo = i === pasoActual
const completado = i < pasoActual
return (
<li key={nombre}>
<button
type="button"
onClick={() => completado && onIrAPaso(i)}
disabled={!completado}
aria-current={activo ? 'step' : undefined}
className={`text-xs uppercase tracking-widest transition-colors duration-150 ${
activo
? 'font-bold text-accent'
: completado
? 'font-medium text-neutral-900 hover:text-accent'
: 'font-light text-neutral-400'
}`}
title={nombre}
>
<span className="mr-2">{String(i + 1).padStart(2, '0')}</span>
<span className="hidden md:inline">{nombre}</span>
</button>
</li>
)
})}
</ol>
</nav>
)
}

View File

@@ -0,0 +1,79 @@
import type {
EstadoCivil,
RegimenBienes,
TipoVivienda,
TipoCompra,
TipoEmpleo,
TipoEconomia,
TipoProducto,
TipoCredito,
SlotDocumento,
} from '../types'
export const PROYECTOS = ['RESIDENCIAL MONTEBELLO', 'SENDA', 'DIAGONAL', 'OTRO'] as const
export const ESTADOS_CIVILES: { valor: EstadoCivil; etiqueta: string }[] = [
{ valor: 'SOLTERO', etiqueta: 'Soltero(a)' },
{ valor: 'CASADO', etiqueta: 'Casado(a)' },
{ valor: 'UNION_LIBRE', etiqueta: 'Unión libre' },
{ valor: 'DIVORCIADO', etiqueta: 'Divorciado(a)' },
{ valor: 'VIUDO', etiqueta: 'Viudo(a)' },
]
export const REGIMENES_BIENES: { valor: RegimenBienes; etiqueta: string }[] = [
{ valor: 'SOCIEDAD_CONYUGAL', etiqueta: 'Sociedad conyugal' },
{ valor: 'SEPARACION_BIENES', etiqueta: 'Separación de bienes' },
]
export const TIPOS_VIVIENDA: { valor: TipoVivienda; etiqueta: string }[] = [
{ valor: 'PRESTADA', etiqueta: 'Prestada' },
{ valor: 'RENTADA', etiqueta: 'Rentada' },
{ valor: 'FAMILIAR', etiqueta: 'Familiar' },
{ valor: 'PROPIA', etiqueta: 'Propia' },
]
export const TIPOS_COMPRA: { valor: TipoCompra; etiqueta: string }[] = [
{ valor: 'INVERSION', etiqueta: 'Inversión' },
{ valor: 'VIVIR_AHI', etiqueta: 'Vivir ahí' },
]
export const TIPOS_EMPLEO: { valor: TipoEmpleo; etiqueta: string }[] = [
{ valor: 'ASALARIADO', etiqueta: 'Asalariado' },
{ valor: 'INDEPENDIENTE', etiqueta: 'Independiente' },
]
export const TIPOS_ECONOMIA: { valor: TipoEconomia; etiqueta: string }[] = [
{ valor: 'E_MEXICANA', etiqueta: 'Economía mexicana' },
{ valor: 'E_AMERICANA', etiqueta: 'Economía americana' },
{ valor: 'MIXTO', etiqueta: 'Mixta' },
]
export const TIPOS_PRODUCTO: { valor: TipoProducto; etiqueta: string }[] = [
{ valor: 'PESOS_FIJOS', etiqueta: 'Pesos fijos' },
{ valor: 'PESOS_CRECIENTES', etiqueta: 'Pesos crecientes' },
{ valor: 'VSM', etiqueta: 'VSM' },
]
export const TIPOS_CREDITO: { valor: TipoCredito; etiqueta: string }[] = [
{ valor: 'INFONAVIT', etiqueta: 'Infonavit' },
{ valor: 'APOYO_INFONAVIT', etiqueta: 'Apoyo Infonavit' },
{ valor: 'COFINAVIT', etiqueta: 'Cofinavit' },
{ valor: 'SEGUNDO_CREDITO', etiqueta: 'Segundo crédito' },
{ valor: 'CONTADO', etiqueta: 'Contado' },
{ valor: 'FOVISSSTE', etiqueta: 'Fovissste' },
{ valor: 'COFI_FOVISSSTE', etiqueta: 'Cofi-Fovissste' },
{ valor: 'BANCARIO', etiqueta: 'Bancario' },
]
export const SLOTS_DOCUMENTOS: { slot: SlotDocumento; etiqueta: string }[] = [
{ slot: 'INE', etiqueta: 'Identificación oficial (INE)' },
{ slot: 'CONSTANCIA_RFC', etiqueta: 'Constancia RFC / CSF' },
{ slot: 'CURP', etiqueta: 'CURP' },
{ slot: 'COMPROBANTE_DOMICILIO', etiqueta: 'Comprobante de domicilio' },
{ slot: 'ACTA_NACIMIENTO', etiqueta: 'Acta de nacimiento' },
]
export const TAMANO_MAX_DOCUMENTO = 10 * 1024 * 1024 // 10 MB
export const ASESOR_DEFAULT = 'ESMERALDA BECERRA DOMINGUEZ'
export const AUTORIDAD_ID_DEFAULT = 'INSTITUTO NACIONAL ELECTORAL'

View File

@@ -0,0 +1,148 @@
// Textos legales copiados textualmente de los formatos originales de Frasa Desarrollos
// (reference/alta-clientes/). NO corregir ortografía ni redacción: son documentos legales.
/** Aviso de privacidad sinóptico — versión completa del Excel (excel_aviso_de_privacidad.txt) */
export const AVISO_PRIVACIDAD_TITULO = 'AVISO DE PRIVACIDAD\n(SINOPTICO)'
export const AVISO_PRIVACIDAD_PARRAFOS: string[] = [
'Se constituye el presente AVISO DE PRIVACIDAD de acuerdo a lo previsto en la Ley Federal de Protección de Datos Personales en Posesión de los Particulares, el reglamento de la misma, y demás leyes aplicables, declarando la moral, FRASA DESARROLLOS SAPI DE C.V, (en adelante “LA MORAL”) ser una Sociedad Mercantil legalmente constituida de conformidad con las leyes de los Estados Unidos Mexicanos, con domicilio en Blvd. Fundadores No. 3078-E, Colonia Juárez, Tijuana Baja California, misma que en adelante será responsable del tratamiento de sus datos personales y sensibles.',
'“LA MORAL” manifiesta que en cumplimiento de la Ley en referencia y su reglamento así como sus propios lineamientos corporativos, los datos personales y/o sensibles que usted nos proporcione, de manera enunciativa mas no limitativa se señalan como tales, nombre completo, domicilio, fecha de nacimiento, número telefónico, cuenta de correo particular, datos financieros, patrimoniales, entre otros; serán tratados como “Información Confidencial” y de conformidad con las leyes aplicables, y podrán ser recabados por “LA MORAL”, siempre y cuando el titular de los mismos datos personales y/o sensibles los proporcione voluntariamente a través de cualquier medio; manifestando que solo serán compartidos o transmitidos por empresas que integran el denominado grupo FRASA DESARROLLOS, no con personas distintas, ya que para evitar su divulgación toma las acciones necesarias y los procedimientos de seguridad que se requieran para ello. (A excepción de que sea solicitada por alguna autoridad, a quien le pudiera ser proporcionada).',
'Derivado de lo anterior, el presente aviso de privacidad tiene como finalidad obtener información para realizar las gestiones correspondientes para la obtención de créditos, actualización de base de datos, relación comercial, de proveeduría, contractual, de servicios, y cualquier finalidad análoga o compatible con las anteriores, sin que la firma del presente implique una obligación de contratación o la celebración de una relación contractual. Asimismo, “LA MORAL” manifiesta que los datos personales, se podrán utilizar en campañas de publicidad y mercadotecnia, administración interna y otros fines análogos y lícitos.',
'Se entiende que usted consciente tácitamente el tratamiento de sus datos personales y sensibles, cuando nos proporciona expresamente su autorización o voluntariamente su información, datos personales y sensibles por cualquier medio, y/o cuando se pone a su disposición el presente aviso de privacidad, y usted no manifiesta de manera expresa su oposición.',
'Usted podrá actualizar su información o manifestar que no desea recibir información o revocar su consentimiento en cualquier momento sin que se le atribuyan efectos retroactivos, en caso de no contar con oposición expresa o especificación, “LA MORAL” podrá a través de los medios y procedimientos implementados libremente y a consideración de “LA MORAL”. Si desea conocer dichos medios y procedimientos, puede ponerse en contacto o podrá presentar sus solicitudes de derechos de acceso, rectificación, cancelación u oposición (ARCO) y/o la revocación de su consentimiento mediante envió al correo electrónico info@frasa.com.mx o al teléfono 6768100.',
'Para mayor información acerca del contenido completo del presente, del tratamiento de los datos y de los derechos que puede hacer valer, usted puede acceder al aviso de privacidad integral a través del portal www.frasa.com.mx/aviso-de-privacidad/',
'“LA MORAL” se reserva el derecho de modificar o actualizar el presente aviso de privacidad, por lo que cualquier modificación que en el futuro hiciéramos al presente aviso, estará disponible en nuestro portal de internet, y será responsabilidad de quien proporciono los datos personales o sensibles revisar periódicamente el contenido del mismo.',
]
/** Párrafo final del aviso: interpola el nombre del cliente */
export function avisoPrivacidadParrafoFinal(nombreCliente: string): string {
return `El suscrito (a) ${nombreCliente} bajo protesta de decir verdad manifiesto expresamente que he leído el presente, me ha sido explicado y estoy enterado (a) del valor y fuerza legal del mismo, y doy autorización expresa a FRASA DESARROLLOS SAPI DE C.V. para obtener, compilar, almacenar, custodiar, tratar, compartir, trasmitir y usar la información y/o datos personales y sensibles, de conformidad con los establecido en el presente, y en las leyes aplicables.`
}
/** Carta de no pagos en efectivo (anti-lavado) — texto completo del Excel (excel_no_pagos_en_efectivo.txt) */
export function cartaAntilavadoCuerpo(proyecto: string): string {
return `Por medio de la presente le enviamos un cordial saludo y aprovechamos para hacer de su conocimiento que derivado de nuestro compromiso en brindarle un servicio de calidad y al mismo tiempo cumplir con la legislación mexicana, es necesario informarle que en relación al Contrato de Compraventa que desea celebrar con mi representada (Frasa Desarrollos SAPI C.V.) y respecto a su interés de adquirir algún condominio del proyecto ${proyecto}, de esta Ciudad de Tijuana, B.C.; se le hace de su conocimiento que todos los pagos que realice en efectivo para cubrir el precio del inmueble, no deberán sumar la cantidad de $600,000.00 Pesos (SEISCIENTOS MIL PESOS 00/100 MONEDA NACIONAL) o su equivalente en Dólares Moneda Americana, lo anterior como límite durante toda la vigencia del futuro Contrato de Compraventa y hasta que el precio del inmueble haya sido cubierto en su totalidad, y su entonces obligación de pago haya finalizado. Indicándole que los pagos a cuenta del precio del inmueble que elija, y que sean por cantidad superior a la citada, deberán efectuarse por medio de cheque de su cuenta personal o transferencia electrónica de su cuenta bancaria.`
}
export const CARTA_ANTILAVADO_CIERRE =
'Lo anterior es en cumplimiento a lo estatuido en el Artículo 32, fracción I y demás relativos y aplicables de la Ley Federal para la Prevención e Identificación de Operaciones con Recursos de Procedencia Ilícita y demás disposiciones legales vigentes sobre la materia, por lo cual se le hace de su conocimiento que en caso de realizar operaciones o pagos en efectivo por la cantidad mencionada anteriormente, no le serán recibidos, e implicaría la imposición de sanciones en materia fiscal y penal de conformidad con la legislación vigente.'
export const CARTA_ANTILAVADO_DESPEDIDA =
'Sin más por el momento, quedo a su total disposición para cualquier duda o aclaración.'
// Textos del formato PLD de identificación de clientes (pdf_formatos.txt página 4 / excel_antilavado.txt)
export const PLD_TEXTO_PROTECCION_DATOS =
'En términos de lo dispuesto por los Artículos 8, 15, 16, 17 y demás relativos y aplicables de la Ley Federal de Protección de Datos Personales en Posesión de los Particulares, el que suscribe manifiesta que se ha puesto a mi disposición el Aviso de Privacidad a que se refiere la mencionada ley, y se me ha informado que el mismo que se puede consultar en www.frasa.com.mx\nEn cumplimiento a lo dispuesto por la Ley Federal de Protección de Datos Personales en Posesión de los Particulares, así como del Artículo 14 de las reglas de carácter general a que se refiere la Ley Federal para la Prevención e Identificación de Operaciones con Recursos de Procedencia Ilícita, el que suscribe, otorgo mi consentimiento para que el vendedor y/o arrendador designe para la custodia del expediente que contiene mi información personal a Frasa Desarrollos SAPI C.V. , aceptando que dicha información sea proporcionada a cualquiera de las empresas que integran el denominado grupo "frasa desarrollos", para el caso de que realice con cualquiera de ellos, alguna otra operación; y/o con alguna autoridad que lo requiera.'
export const PLD_TEXTO_DOCUMENTOS_INTEGRAN =
'Bajo protesta de decir verdad manifiesto expresamente que los documentos que se agregan al presente formato y que se describen a continuación fueron proporcionados libremente por el suscrito, siendo copia fiel de los originales que se tuvieron a la vista, y que conozco las penas en que incurren quienes declaran con falsedad, de conformidad con lo señalado en el Artículo 62 de la Ley Federal de Prevención e Identificación de Operaciones con Recursos de Procedencia Ilícita, y demás leyes aplicables.'
export const PLD_TEXTO_BENEFICIARIO =
'Bajo protesta de decir verdad manifiesto que tengo conocimiento de la existencia del dueño beneficiario/beneficiario controlador/beneficiario efectivo, firmando al calce para constancia'
export const PLD_TEXTO_RESPONSABILIDAD =
'El suscrito asume totalmente la responsabilidad por cualquier dato falso o inexactitud que existiera en lo declarado en el presente y por la información o documentación proporcionada.'
export const PLD_TEXTO_OTRO_FORMATO =
'Llenar otro formato con los datos y documentos del dueño beneficiario/beneficiario controlador/beneficiario efectivo.'
// Formato de identificación del beneficiario controlador (pdf_formatos.txt página 10)
export function beneficiarioControladorIntro(nombreCliente: string, ubicacion: string, condominio: string): string {
return `El/La suscrito/a ${nombreCliente} en virtud de la futura operación de compraventa respecto del inmueble denominado como unidad habitacional identificada tentativamente como número ${ubicacion} del Condominio Habitacional ${condominio} de esta Ciudad de Tijuana, B.C., que deseo celebrar con la moral FRASA DESARROLLOS, S.A.P.I. de C.V., por medio de la presente expresamente manifiesto lo siguiente: Que estoy consciente que la naturaleza jurídica de la futura operación de compraventa que deseo celebrar, constituye una actividad vulnerable que podrá ser materia de verificación por la Unidad Especializada en Análisis Financieros de la Procuraduría General de Justicia de la Republica o por la Secretaria de Hacienda y Crédito Publico conforme a lo previsto por los artículos 3, 8, 17 Fracción V y 18 de la Ley Federal para la Prevención e Identificación de Operaciones con Recursos de Procedencia Ilícita. Asimismo, en virtud de lo estipulado en el artículo 3 Fracción III de la Ley Federal para la Prevención e Identificación de Operaciones con Recursos de Procedencia Ilícita, respecto a la definición del Beneficiario Controlador, misma que a continuación es agregada a la letra:`
}
export const BENEFICIARIO_CONTROLADOR_ARTICULO3 =
'Artículo 3. Para los efectos de esta Ley, se entenderá por: I. Actividades Vulnerables, a las actividades que realicen las Entidades Financieras en términos del artículo 14 y a las que se refiere el artículo 17 de esta Ley; II. Avisos, a aquellos que deben presentarse en términos del artículo 17 de la presente Ley, así como a los reportes que deben presentar las entidades financieras en términos del artículo 15, fracción II, de esta Ley; III. Beneficiario Controlador, a la persona o grupo de personas que: a) Por medio de otra o de cualquier acto, obtiene el beneficio derivado de éstos y es quien, en última instancia, ejerce los derechos de uso, goce, disfrute, aprovechamiento o disposición de un bien o servicio, o b) Ejerce el control de aquella persona moral que, en su carácter de cliente o usuario, lleve a cabo actos u operaciones con quien realice Actividades Vulnerables, así como las personas por cuenta de quienes celebra alguno de ellos.'
export const BENEFICIARIO_CONTROLADOR_MANIFIESTO =
'Bajo protesta de decir verdad, manifiesto que soy el único Beneficiario Controlador de la Actividad Vulnerable que se realizará, consistente en la futura compraventa o transmisión de derechos reales sobre el inmueble antes citado, ya que NO existe ningún otro beneficiario, para lo cual se agrega la siguiente información:'
export const BENEFICIARIO_CONTROLADOR_CIERRE =
'Bajo protesta de decir verdad manifiesto expresamente que la información que se agrega, es proporcionada libremente por el/la suscrito/a, por lo que manifiesto que conozco las penas en que incurre quien proporcione de manera dolosa (a quienes deba dar información), documentación, datos o imágenes que sean falsos, o que declare con falsedad. Derivado de lo anterior, manifiesto mi conformidad en que esta información sea parte del archivo de identificación del cliente que ordena la legislación aplicable, firma la presente declaración bajo protesta de decir verdad, en la ciudad de Tijuana, B.C.'
// Checklist de expediente (pdf_formatos.txt página 1)
export const CHECKLIST_BASICO: string[] = [
'Formatos Generales***',
'Identificación oficial',
'Validación Identificación*',
'CURP',
'RFC',
'Acta de nacimiento original',
'Comprobante de domicilio',
'Cotizador firmado',
'Carta compromiso',
'Contrato original (3 tantos)',
'Copia de recibos',
]
export const CHECKLIST_ANTES_ESCRITURA: string[] = [
'Comprobación de ingresos',
'Acta de nacimiento conyuge',
'Acta de matrimonio',
'Solicitud de crédito',
'Buró de crédito',
]
export const CHECKLIST_NOTAS: string[] = [
'Los expedientes básicos deberán ser entregados con el mínimo de información requerida por administración y finanzas para hacer válido el pago de comisión, esto refiere a entregar: **Generales, **formatos de ley anti-lavado, contrato correctamente impreso y firmado en 3 tantos, carta compromiso, cotizador firmado con esquema de pago autorizado al cliente, Identificación oficial y comprobante de domicilio, copia de los recibos de pago y comprobantes de los mismos.',
'Los expedientes se deberán entregar una vez realizada la venta (pago de apartado + enganche + firma de contrato y carta compromiso) en un máximo de 48h posteriores a realizar la operación.',
'Será responsabilidad del asesor de ventas y de Gerencia el seguimiento a la integración del expediente para completar el proceso de venta hasta la escrituración.',
]
// Requisitos generales (pdf_formatos.txt página 8)
export const REQUISITOS_GENERALES: { titulo: string; items: string[] }[] = [
{
titulo: 'Documentos Generales:',
items: [
'Solicitud de crédito hipotecario (En caso de Infonavit, hoja número 3)',
'Autorización de consulta Buró de crédito',
'Copia de identificación oficial vigente con fotografía y firma de los solicitantes',
'Acta de nacimiento original y matrimonio (en su caso)',
'Comprobante de domicilio',
],
},
{
titulo: 'Documentos de Ingresos en México:',
items: [
'Carta original de empleo (Firmada por un funcionario autorizado en papelería membretada y con sello de la empresa, que contenga nombre del solicitante, R.F.C., puesto, antigüedad en la empresa, número de seguro social o de fovissste e ingresos mensuales brutos)',
'Últimos 3 meses de recibos de nómina originales',
'Estados de cuenta bancarios de 3 meses donde se refleje el concepto de abono de nómina',
],
},
{
titulo: 'Personas Físicas con actividad empresarial:',
items: [
'Copia de alta en Hacienda debidamente sellada',
'Copia de la Declaración anual de los 2 últimos ejercicios y parciales del año en curso',
'Copia de estados de cuenta de cheques de los últimos 6 meses',
],
},
{
titulo: 'Documento de ingresos en U.S.A.',
items: [
'Comprobante de Domicilio Reciente (no mayor a 3 meses)',
'Carta original de empleo',
'Formato W2 y 1040 (Income Tax) de 1 año en el mismo trabajo',
'Recibos de nómina de 3 meses',
'Reporte detallado de Buró de Crédito en U.S.A., con score superior a 680 puntos',
'Copia de estados de cuenta de cheques de los últimos 6 meses',
'Copia de Social Security',
],
},
{
titulo: 'ADEMÁS: Si es Persona Fisica con actividad empresarial formal o informal en U.S.A.',
items: [
'Carta a título personal de actividad (Mencionar ingreso promedio mensual, tipo de negocio y fecha de inicio)',
'Copia de 6 meses de estados de cuenta',
'Formato 1099 a 1040 (Income Tax del año anterior)',
'Permiso de operación Vigente',
'3 Referencias en U.S.A.',
],
},
]
export const REQUISITOS_CONTACTO_ENTREGA =
'Contacto: Graciela Bautista Velasco\nEJECUTIVO DE CRÉDITO IN-HOUSE\nOficina: (664) 676 8120\nCorreo: gbautista@frasa.com.mx'
export const REQUISITOS_CONTACTO_CONTRATO =
'Asesor: ALEJANDRA GUERRERO SANCHEZ\nOficina: COLINAS DE SAN PABLO 17516 COLINAS DE LA PRESA 221\nCelular: 6644275217\nCorreo: auxventas@frasa.com.mx'

View File

@@ -0,0 +1,45 @@
import { useForm, FormProvider } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import type { Coacreditado } from '../types'
import { coacreditadoSchema } from '../lib/schemas'
import DatosGeneralesCampos from './DatosGeneralesCampos'
import EmpleoCampos from './EmpleoCampos'
import EconomicosCampos from './EconomicosCampos'
import Navegacion from './Navegacion'
import type { PasoProps } from './DatosGeneralesForm'
/** Paso 4: co-acreditado o cónyuge (opcional, toggle) */
export default function CoacreditadoForm({ valores, onAnterior, onSiguiente }: PasoProps<Coacreditado>) {
const metodos = useForm<Coacreditado>({
resolver: zodResolver(coacreditadoSchema),
defaultValues: valores,
mode: 'onSubmit',
})
const tieneCoacreditado = metodos.watch('tieneCoacreditado')
return (
<FormProvider {...metodos}>
<form onSubmit={metodos.handleSubmit(onSiguiente)} noValidate>
<label htmlFor="tieneCoacreditado" className="flex items-center gap-3 border border-neutral-200 bg-white p-6">
<input
id="tieneCoacreditado"
type="checkbox"
className="h-5 w-5 accent-accent"
{...metodos.register('tieneCoacreditado')}
/>
<span className="text-xs font-bold uppercase tracking-widest text-neutral-900">¿Hay co-acreditado o cónyuge?</span>
</label>
{tieneCoacreditado && (
<div className="mt-16 space-y-16">
<DatosGeneralesCampos prefix="generales." compacto />
<EmpleoCampos prefix="empleo." />
<EconomicosCampos prefix="economicos." />
</div>
)}
<Navegacion onAnterior={onAnterior} />
</form>
</FormProvider>
)
}

View File

@@ -0,0 +1,69 @@
import { useFormContext } from 'react-hook-form'
import { CampoTexto, CampoSelect } from '../components/Campos'
import SectionCard from '../components/SectionCard'
import { compararConDatos } from '../lib/curp'
import { ESTADOS_CIVILES, REGIMENES_BIENES, TIPOS_VIVIENDA, TIPOS_COMPRA } from '../data/constantes'
interface Props {
prefix: string
compacto?: boolean
}
/** Campos de datos generales; reutilizable con prefijo (cliente y co-acreditado) */
export default function DatosGeneralesCampos({ prefix, compacto = false }: Props) {
const { watch } = useFormContext()
const p = (campo: string) => `${prefix}${campo}`
const estadoCivil = watch(p('estadoCivil')) as string
const curp = (watch(p('curp')) as string) ?? ''
const fechaNacimiento = (watch(p('fechaNacimiento')) as string) ?? ''
const lugarNacimiento = (watch(p('lugarNacimiento')) as string) ?? ''
// Advertencias ámbar en vivo: no bloquean, solo alertan inconsistencias con la CURP
const advertenciasCurp = compararConDatos(curp, { fechaNacimiento, lugarNacimiento })
return (
<SectionCard titulo="Datos generales">
{!compacto && <CampoTexto nombre={p('campaniaVenta')} etiqueta="Campaña de la venta" />}
<CampoTexto nombre={p('fecha')} etiqueta="Fecha" tipo="date" requerido={!compacto} soloLectura={compacto} />
<CampoTexto nombre={p('nombreCompleto')} etiqueta="Nombre completo" requerido={!compacto} className="sm:col-span-2" />
<CampoTexto nombre={p('nacionalidad')} etiqueta="Nacionalidad" />
<CampoTexto nombre={p('nivelEstudios')} etiqueta="Nivel de estudios" />
<CampoTexto nombre={p('curp')} etiqueta="CURP" requerido={!compacto} placeholder="XXXX000000XXXXXXX0" />
{advertenciasCurp.length > 0 && (
<div role="alert" className="border border-amber-300 bg-amber-50 px-4 py-3 sm:col-span-2">
{advertenciasCurp.map((aviso) => (
<p key={aviso} className="text-xs text-amber-700">
{aviso}
</p>
))}
</div>
)}
<CampoTexto nombre={p('rfc')} etiqueta="RFC" requerido={!compacto} placeholder="XXXX000000XXX" />
<CampoTexto nombre={p('nss')} etiqueta="NSS" />
<CampoTexto nombre={p('delegacionMunicipio')} etiqueta="Delegación / Municipio" />
<CampoTexto nombre={p('direccion')} etiqueta="Dirección" className="sm:col-span-2" />
<CampoTexto nombre={p('codigoPostal')} etiqueta="Código postal" placeholder="5 dígitos" />
<CampoTexto nombre={p('antiguedadDomicilio')} etiqueta="Antigüedad en domicilio (años)" />
<CampoTexto nombre={p('fechaNacimiento')} etiqueta="Fecha de nacimiento" tipo="date" requerido={!compacto} />
<CampoTexto nombre={p('lugarNacimiento')} etiqueta="Lugar de nacimiento" />
<CampoSelect nombre={p('estadoCivil')} etiqueta="Estado civil" opciones={ESTADOS_CIVILES} />
{estadoCivil === 'CASADO' && (
<CampoSelect nombre={p('regimenBienes')} etiqueta="Régimen de bienes" opciones={REGIMENES_BIENES} requerido={!compacto} />
)}
<CampoTexto nombre={p('peso')} etiqueta="Peso (kg)" />
<CampoTexto nombre={p('estatura')} etiqueta="Estatura (m)" />
<CampoTexto nombre={p('email')} etiqueta="Email" tipo="email" requerido={!compacto} />
<CampoTexto nombre={p('telCasa')} etiqueta="Tel. casa" />
<CampoTexto nombre={p('celular')} etiqueta="Celular" requerido={!compacto} />
<CampoTexto nombre={p('viviendaAnios')} etiqueta="Años en vivienda" />
<CampoSelect nombre={p('tipoVivienda')} etiqueta="Tipo de vivienda" opciones={TIPOS_VIVIENDA} />
<CampoSelect nombre={p('tipoCompra')} etiqueta="Tipo de compra" opciones={TIPOS_COMPRA} />
{!compacto && (
<CampoTexto
nombre={p('actividadOcupacion')}
etiqueta="Actividad, ocupación o profesión"
className="sm:col-span-2"
/>
)}
</SectionCard>
)
}

View File

@@ -0,0 +1,30 @@
import { useForm, FormProvider } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import type { DatosGenerales } from '../types'
import { datosGeneralesSchema } from '../lib/schemas'
import DatosGeneralesCampos from './DatosGeneralesCampos'
import Navegacion from './Navegacion'
export interface PasoProps<T> {
valores: T
onAnterior?: () => void
onSiguiente: (datos: T) => void
}
/** Paso 1: datos generales del cliente */
export default function DatosGeneralesForm({ valores, onSiguiente }: PasoProps<DatosGenerales>) {
const metodos = useForm<DatosGenerales>({
resolver: zodResolver(datosGeneralesSchema),
defaultValues: valores,
mode: 'onSubmit',
})
return (
<FormProvider {...metodos}>
<form onSubmit={metodos.handleSubmit(onSiguiente)} noValidate>
<DatosGeneralesCampos prefix="" />
<Navegacion />
</form>
</FormProvider>
)
}

View File

@@ -0,0 +1,59 @@
import type { DatosGenerales, DocumentoCargado, SlotDocumento } from '../types'
import FileUploader from '../components/FileUploader'
import ChecklistExpediente from '../components/ChecklistExpediente'
import BotonOcrIne from '../components/BotonOcrIne'
import SectionCard from '../components/SectionCard'
import Navegacion from './Navegacion'
import { SLOTS_DOCUMENTOS } from '../data/constantes'
type CamposAplicables = Partial<Pick<DatosGenerales, 'curp' | 'nombreCompleto' | 'fechaNacimiento'>>
interface Props {
documentos: DocumentoCargado[]
firmaDataUrl: string
onAgregar: (doc: DocumentoCargado) => void
onQuitar: (slot: SlotDocumento) => void
onAplicarDatosGenerales: (campos: CamposAplicables) => void
onAnterior: () => void
onSiguiente: () => void
}
/** Paso 7: carga de documentos del expediente (sin backend, se guardan en estado) */
export default function DocumentosForm({
documentos,
firmaDataUrl,
onAgregar,
onQuitar,
onAplicarDatosGenerales,
onAnterior,
onSiguiente,
}: Props) {
const ine = documentos.find((d) => d.slot === 'INE')
return (
<div>
<div className="mb-8">
<ChecklistExpediente documentos={documentos} firmaDataUrl={firmaDataUrl} />
</div>
<SectionCard
titulo="Documentos del expediente"
descripcion="Los archivos se conservan en memoria y se enviarán al backend junto con el alta."
>
{SLOTS_DOCUMENTOS.map(({ slot, etiqueta }) => (
<div key={slot} className="space-y-4">
<FileUploader
slot={slot}
etiqueta={etiqueta}
documento={documentos.find((d) => d.slot === slot)}
onAgregar={onAgregar}
onQuitar={onQuitar}
/>
{slot === 'INE' && ine && ine.tipoMime.startsWith('image/') && (
<BotonOcrIne archivo={ine.archivo} onAplicar={onAplicarDatosGenerales} />
)}
</div>
))}
</SectionCard>
<Navegacion onAnterior={onAnterior} onSiguiente={onSiguiente} />
</div>
)
}

View File

@@ -0,0 +1,51 @@
import { useFormContext } from 'react-hook-form'
import { CampoNumero, CampoTexto } from '../components/Campos'
import SectionCard from '../components/SectionCard'
import { formatoMoneda } from '../lib/formatters'
interface Props {
prefix: string
}
/** Campos de datos económicos con totales calculados de solo lectura */
export default function EconomicosCampos({ prefix }: Props) {
const { watch } = useFormContext()
const p = (campo: string) => `${prefix}${campo}`
const ingresosMensuales = Number(watch(p('ingresosMensuales'))) || 0
const otrosIngresos = Number(watch(p('otrosIngresos'))) || 0
const gastosLuz = Number(watch(p('gastosLuz'))) || 0
const gastosOtros = Number(watch(p('gastosOtros'))) || 0
return (
<SectionCard titulo="Datos económicos">
<CampoNumero nombre={p('ingresosMensuales')} etiqueta="Ingresos mensuales" />
<CampoNumero nombre={p('otrosIngresos')} etiqueta="Otros ingresos" />
<label htmlFor={`${prefix}totalIngresos`} className="block">
<span className="mb-1 block text-xs font-bold uppercase tracking-widest text-neutral-500">Total ingresos</span>
<input
id={`${prefix}totalIngresos`}
type="text"
readOnly
value={formatoMoneda(ingresosMensuales + otrosIngresos)}
className="w-full border border-neutral-200 bg-neutral-50 px-3 py-2.5 text-sm font-bold text-neutral-900"
/>
</label>
<CampoNumero nombre={p('gastosLuz')} etiqueta="Gastos luz" />
<CampoNumero nombre={p('gastosOtros')} etiqueta="Otros gastos" />
<label htmlFor={`${prefix}gastosTotal`} className="block">
<span className="mb-1 block text-xs font-bold uppercase tracking-widest text-neutral-500">Gastos total</span>
<input
id={`${prefix}gastosTotal`}
type="text"
readOnly
value={formatoMoneda(gastosLuz + gastosOtros)}
className="w-full border border-neutral-200 bg-neutral-50 px-3 py-2.5 text-sm font-bold text-neutral-900"
/>
</label>
<CampoTexto nombre={p('dependientes.personas')} etiqueta="Dependientes económicos (personas)" />
<CampoTexto nombre={p('dependientes.parentesco')} etiqueta="Parentesco" />
<CampoTexto nombre={p('dependientes.edad')} etiqueta="Edad" />
</SectionCard>
)
}

View File

@@ -0,0 +1,25 @@
import { useForm, FormProvider } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import type { DatosEconomicos } from '../types'
import { economicosSchema } from '../lib/schemas'
import EconomicosCampos from './EconomicosCampos'
import Navegacion from './Navegacion'
import type { PasoProps } from './DatosGeneralesForm'
/** Paso 3: datos económicos del cliente */
export default function EconomicosForm({ valores, onAnterior, onSiguiente }: PasoProps<DatosEconomicos>) {
const metodos = useForm<DatosEconomicos>({
resolver: zodResolver(economicosSchema),
defaultValues: valores,
mode: 'onSubmit',
})
return (
<FormProvider {...metodos}>
<form onSubmit={metodos.handleSubmit(onSiguiente)} noValidate>
<EconomicosCampos prefix="" />
<Navegacion onAnterior={onAnterior} />
</form>
</FormProvider>
)
}

View File

@@ -0,0 +1,30 @@
import { CampoTexto, CampoSelect } from '../components/Campos'
import SectionCard from '../components/SectionCard'
import { TIPOS_EMPLEO, TIPOS_ECONOMIA } from '../data/constantes'
interface Props {
prefix: string
}
/** Campos de datos de empleo; reutilizable con prefijo (cliente y co-acreditado) */
export default function EmpleoCampos({ prefix }: Props) {
const p = (campo: string) => `${prefix}${campo}`
return (
<SectionCard titulo="Datos de empleo">
<CampoSelect nombre={p('tipoEmpleo')} etiqueta="Tipo de empleo" opciones={TIPOS_EMPLEO} />
<CampoSelect nombre={p('tipoEconomia')} etiqueta="Tipo de economía" opciones={TIPOS_ECONOMIA} />
<CampoTexto nombre={p('compania')} etiqueta="Compañía" />
<CampoTexto nombre={p('antiguedadAnios')} etiqueta="Antigüedad (años)" />
<CampoTexto nombre={p('antiguedadMeses')} etiqueta="Antigüedad (meses)" />
<CampoTexto nombre={p('codigoPostal')} etiqueta="Código postal" />
<CampoTexto nombre={p('direccion')} etiqueta="Dirección" className="sm:col-span-2" />
<CampoTexto nombre={p('colonia')} etiqueta="Colonia" />
<CampoTexto nombre={p('numero')} etiqueta="Número" />
<CampoTexto nombre={p('puesto')} etiqueta="Puesto" />
<CampoTexto nombre={p('telefono')} etiqueta="Teléfono" />
<CampoTexto nombre={p('ext')} etiqueta="Ext." />
<CampoTexto nombre={p('delegacion')} etiqueta="Delegación" />
</SectionCard>
)
}

View File

@@ -0,0 +1,25 @@
import { useForm, FormProvider } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import type { DatosEmpleo } from '../types'
import { empleoSchema } from '../lib/schemas'
import EmpleoCampos from './EmpleoCampos'
import Navegacion from './Navegacion'
import type { PasoProps } from './DatosGeneralesForm'
/** Paso 2: datos de empleo del cliente */
export default function EmpleoForm({ valores, onAnterior, onSiguiente }: PasoProps<DatosEmpleo>) {
const metodos = useForm<DatosEmpleo>({
resolver: zodResolver(empleoSchema),
defaultValues: valores,
mode: 'onSubmit',
})
return (
<FormProvider {...metodos}>
<form onSubmit={metodos.handleSubmit(onSiguiente)} noValidate>
<EmpleoCampos prefix="" />
<Navegacion onAnterior={onAnterior} />
</form>
</FormProvider>
)
}

View File

@@ -0,0 +1,172 @@
import { useState } from 'react'
import type { AltaCliente, DatosFirma } from '../types'
import { firmaSchema } from '../lib/schemas'
import { formatoMoneda, formatoFechaCorta } from '../lib/formatters'
import { calcularTotalIngresos } from '../lib/calculations'
import { estadoExpediente } from '../lib/expediente'
import SectionCard from '../components/SectionCard'
import SignaturePad from '../components/SignaturePad'
import ChecklistExpediente from '../components/ChecklistExpediente'
import * as perfilCliente from '../lib/pdf/perfilCliente'
import * as identificacionCliente from '../lib/pdf/identificacionCliente'
import * as avisoPrivacidad from '../lib/pdf/avisoPrivacidad'
import * as cartaAntilavado from '../lib/pdf/cartaAntilavado'
import * as checklistExpediente from '../lib/pdf/checklistExpediente'
interface Props {
alta: AltaCliente
onCambiarFirma: (firma: DatosFirma) => void
onAnterior: () => void
}
const GENERADORES: { tipo: string; descargar: (alta: AltaCliente) => void }[] = [
{ tipo: 'Perfil del cliente', descargar: perfilCliente.descargar },
{ tipo: 'Identificación de cliente (PLD)', descargar: identificacionCliente.descargar },
{ tipo: 'Aviso de privacidad', descargar: avisoPrivacidad.descargar },
{ tipo: 'Carta anti-lavado', descargar: cartaAntilavado.descargar },
{ tipo: 'Checklist de expediente', descargar: checklistExpediente.descargar },
]
/** Paso 8: firma del cliente, nombre del asesor, resumen y generación de PDFs */
export default function FirmasForm({ alta, onCambiarFirma, onAnterior }: Props) {
const [error, setError] = useState('')
const [avisoExpediente, setAvisoExpediente] = useState('')
const { datosGenerales: g, viviendaCredito: v, economicos, firma } = alta
const proyecto = v.proyecto === 'OTRO' ? v.proyectoOtro : v.proyecto
const expediente = estadoExpediente(alta.documentos, firma.firmaDataUrl)
const validar = (): boolean => {
const r = firmaSchema.safeParse(firma)
setError(r.success ? '' : (r.error.issues[0]?.message ?? 'Revisa la firma'))
return r.success
}
// Advierte (sin bloquear) si el expediente está incompleto al generar PDFs
const avisarExpediente = () => {
setAvisoExpediente(
expediente.completoTodo
? ''
: `Expediente incompleto (${expediente.completos}/${expediente.total}): puedes generar los PDFs, pero faltan documentos o la firma.`,
)
}
const generar = (descargar: (a: AltaCliente) => void) => {
avisarExpediente()
if (validar()) descargar(alta)
}
const generarTodos = () => {
avisarExpediente()
if (!validar()) return
for (const g2 of GENERADORES) g2.descargar(alta)
}
return (
<div className="space-y-16">
<SectionCard titulo="Resumen del alta">
<dl className="grid grid-cols-2 gap-x-6 gap-y-4 text-sm sm:col-span-2 lg:col-span-3 lg:grid-cols-3">
<div>
<dt className="text-xs font-bold uppercase tracking-widest text-neutral-500">Cliente</dt>
<dd className="mt-1 font-light text-neutral-900">{g.nombreCompleto}</dd>
</div>
<div>
<dt className="text-xs font-bold uppercase tracking-widest text-neutral-500">CURP</dt>
<dd className="mt-1 font-light text-neutral-900">{g.curp}</dd>
</div>
<div>
<dt className="text-xs font-bold uppercase tracking-widest text-neutral-500">RFC</dt>
<dd className="mt-1 font-light text-neutral-900">{g.rfc}</dd>
</div>
<div>
<dt className="text-xs font-bold uppercase tracking-widest text-neutral-500">Proyecto</dt>
<dd className="mt-1 font-light text-neutral-900">{proyecto || '—'}</dd>
</div>
<div>
<dt className="text-xs font-bold uppercase tracking-widest text-neutral-500">Ubicación / Modelo</dt>
<dd className="mt-1 font-light text-neutral-900">
{v.ubicacion || '—'} {v.modelo}
</dd>
</div>
<div>
<dt className="text-xs font-bold uppercase tracking-widest text-neutral-500">Valor venta</dt>
<dd className="mt-1 font-light text-neutral-900">{formatoMoneda(Number(v.valorVenta) || 0)}</dd>
</div>
<div>
<dt className="text-xs font-bold uppercase tracking-widest text-neutral-500">Total ingresos</dt>
<dd className="mt-1 font-light text-neutral-900">{formatoMoneda(calcularTotalIngresos(economicos))}</dd>
</div>
<div>
<dt className="text-xs font-bold uppercase tracking-widest text-neutral-500">Fecha</dt>
<dd className="mt-1 font-light text-neutral-900">{formatoFechaCorta(g.fecha)}</dd>
</div>
<div>
<dt className="text-xs font-bold uppercase tracking-widest text-neutral-500">Documentos</dt>
<dd className="mt-1 font-light text-neutral-900">{alta.documentos.length} de 5</dd>
</div>
</dl>
</SectionCard>
<ChecklistExpediente documentos={alta.documentos} firmaDataUrl={firma.firmaDataUrl} />
<SectionCard titulo="Firma del cliente" descripcion="La firma se incrusta en los PDFs generados.">
<div className="sm:col-span-2">
<SignaturePad onFirma={(dataUrl) => onCambiarFirma({ ...firma, firmaDataUrl: dataUrl })} />
</div>
<label htmlFor="asesorNombre" className="block">
<span className="mb-1 block text-xs font-bold uppercase tracking-widest text-neutral-500">
Nombre del asesor <span className="text-red-700">*</span>
</span>
<input
id="asesorNombre"
type="text"
value={firma.asesorNombre}
onChange={(e) => onCambiarFirma({ ...firma, asesorNombre: e.target.value.toUpperCase() })}
className="w-full border border-neutral-300 bg-white px-3 py-2.5 text-sm uppercase transition-colors duration-150 focus:border-neutral-900 focus:outline-none"
/>
</label>
</SectionCard>
{error && <p className="text-xs text-red-700">{error}</p>}
<SectionCard titulo="Generar documentos PDF">
{avisoExpediente && (
<p
role="alert"
className="border border-amber-300 bg-amber-50 px-4 py-3 text-xs text-amber-700 sm:col-span-2 lg:col-span-3"
>
{avisoExpediente}
</p>
)}
<div className="flex flex-wrap gap-3 sm:col-span-2 lg:col-span-3">
{GENERADORES.map((g3) => (
<button
key={g3.tipo}
type="button"
onClick={() => generar(g3.descargar)}
className="border border-neutral-900 bg-transparent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-neutral-900 transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
>
{g3.tipo}
</button>
))}
<button
type="button"
onClick={generarTodos}
className="bg-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:bg-neutral-900 disabled:opacity-40"
>
Generar todos
</button>
</div>
</SectionCard>
<div>
<button
type="button"
onClick={onAnterior}
className="border border-neutral-900 bg-transparent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-neutral-900 transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
>
Anterior
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,32 @@
interface Props {
onAnterior?: () => void
/** Si se pasa, el botón Siguiente es type="button" (pasos sin formulario) */
onSiguiente?: () => void
textoSiguiente?: string
}
/** Botones de navegación del wizard */
export default function Navegacion({ onAnterior, onSiguiente, textoSiguiente = 'Siguiente' }: Props) {
return (
<div className="mt-16 flex items-center justify-between">
{onAnterior ? (
<button
type="button"
onClick={onAnterior}
className="border border-neutral-900 bg-transparent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-neutral-900 transition-colors duration-150 hover:bg-neutral-900 hover:text-white"
>
Anterior
</button>
) : (
<span />
)}
<button
type={onSiguiente ? 'button' : 'submit'}
onClick={onSiguiente}
className="bg-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:bg-neutral-900 disabled:opacity-40"
>
{textoSiguiente}
</button>
</div>
)
}

View File

@@ -0,0 +1,52 @@
import { z } from 'zod'
import { useForm, FormProvider } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import type { Referencia } from '../types'
import { referenciasSchema } from '../lib/schemas'
import { CampoTexto } from '../components/Campos'
import SectionCard from '../components/SectionCard'
import Navegacion from './Navegacion'
import type { PasoProps } from './DatosGeneralesForm'
// RHF trabaja con objetos raíz; el array va envuelto
const schemaFormulario = z.object({ referencias: referenciasSchema })
/** Paso 5: referencias personales (4 filas, mínimo 1 con nombre y teléfono) */
export default function ReferenciasForm({ valores, onAnterior, onSiguiente }: PasoProps<Referencia[]>) {
const metodos = useForm<{ referencias: Referencia[] }>({
resolver: zodResolver(schemaFormulario),
defaultValues: { referencias: valores },
mode: 'onSubmit',
})
const errorRaiz = metodos.formState.errors.referencias
return (
<FormProvider {...metodos}>
<form
onSubmit={metodos.handleSubmit((datos) => onSiguiente(datos.referencias))}
noValidate
>
<SectionCard
titulo="Referencias personales"
descripcion="Captura al menos 1 referencia con nombre y teléfono."
>
{[0, 1, 2, 3].map((i) => (
<div key={i} className="border border-neutral-200 p-6">
<p className="mb-4 text-xs font-bold uppercase tracking-widest text-neutral-500">Referencia {i + 1}</p>
<div className="space-y-3">
<CampoTexto nombre={`referencias.${i}.nombre`} etiqueta="Nombre completo" />
<CampoTexto nombre={`referencias.${i}.telefono`} etiqueta="Teléfono" />
<CampoTexto nombre={`referencias.${i}.parentesco`} etiqueta="Parentesco" />
<CampoTexto nombre={`referencias.${i}.direccion`} etiqueta="Dirección" />
</div>
</div>
))}
</SectionCard>
{errorRaiz && typeof errorRaiz.message === 'string' && (
<p className="mt-2 text-xs font-semibold text-red-600">{errorRaiz.message}</p>
)}
<Navegacion onAnterior={onAnterior} />
</form>
</FormProvider>
)
}

View File

@@ -0,0 +1,112 @@
import { useForm, FormProvider } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import type { ViviendaCredito, TipoCredito } from '../types'
import { viviendaCreditoSchema } from '../lib/schemas'
import { CampoTexto, CampoSelect, MensajeError } from '../components/Campos'
import SectionCard from '../components/SectionCard'
import Navegacion from './Navegacion'
import type { PasoProps } from './DatosGeneralesForm'
import { PROYECTOS, TIPOS_CREDITO, TIPOS_PRODUCTO } from '../data/constantes'
/** Paso 6: datos de la vivienda, crédito e identificación oficial (PLD) */
export default function ViviendaCreditoForm({ valores, onAnterior, onSiguiente }: PasoProps<ViviendaCredito>) {
const metodos = useForm<ViviendaCredito>({
resolver: zodResolver(viviendaCreditoSchema),
defaultValues: valores,
mode: 'onSubmit',
})
const proyecto = metodos.watch('proyecto')
const tiposSeleccionados = metodos.watch('tiposCredito') ?? []
const alternarCredito = (valor: TipoCredito) => {
const actuales = metodos.getValues('tiposCredito') ?? []
metodos.setValue(
'tiposCredito',
actuales.includes(valor) ? actuales.filter((t) => t !== valor) : [...actuales, valor],
{ shouldDirty: true },
)
}
return (
<FormProvider {...metodos}>
<form onSubmit={metodos.handleSubmit(onSiguiente)} noValidate>
<div className="space-y-16">
<SectionCard titulo="Datos vivienda">
<CampoSelect
nombre="proyecto"
etiqueta="Proyecto"
requerido
opciones={PROYECTOS.map((p) => ({ valor: p, etiqueta: p === 'OTRO' ? 'Otro' : p }))}
/>
{proyecto === 'OTRO' && (
<CampoTexto nombre="proyectoOtro" etiqueta="Nombre del proyecto" requerido />
)}
<CampoTexto nombre="modelo" etiqueta="Modelo" />
<CampoTexto nombre="manzana" etiqueta="Manzana" />
<CampoTexto nombre="lote" etiqueta="Lote" />
<CampoTexto nombre="ubicacion" etiqueta="Ubicación" />
<CampoTexto nombre="superficieM2" etiqueta="Superficie (m²)" />
<CampoTexto nombre="valorVenta" etiqueta="Valor venta ($)" />
<CampoTexto nombre="enganche" etiqueta="Enganche ($)" />
<CampoTexto nombre="equipamientoAdicional" etiqueta="Equipamiento adicional" />
<CampoTexto nombre="fechaPagoEnganche" etiqueta="Fecha pago de enganche" tipo="date" />
<CampoTexto nombre="firmaContrato" etiqueta="Firma de contrato" tipo="date" />
</SectionCard>
<SectionCard titulo="Tipo de crédito">
<div className="sm:col-span-2 lg:col-span-3">
<div className="flex flex-wrap gap-2">
{TIPOS_CREDITO.map((tc) => (
<label
key={tc.valor}
htmlFor={`tipoCredito-${tc.valor}`}
className={`cursor-pointer border px-3 py-1.5 text-xs font-bold uppercase tracking-wider transition-colors duration-150 ${
tiposSeleccionados.includes(tc.valor)
? 'border-accent bg-accent text-white'
: 'border-neutral-300 bg-white text-neutral-600 hover:border-neutral-900'
}`}
>
<input
id={`tipoCredito-${tc.valor}`}
type="checkbox"
className="sr-only"
checked={tiposSeleccionados.includes(tc.valor)}
onChange={() => alternarCredito(tc.valor)}
/>
{tc.etiqueta}
</label>
))}
</div>
</div>
<CampoTexto nombre="montoCreditoInfoFovi" etiqueta="Monto de crédito Info o Fovi ($)" />
<CampoTexto nombre="ahorroVoluntarioSubcuenta" etiqueta="Ahorro voluntario o subcuenta ($)" />
<CampoTexto nombre="descuentoMensualInfoFovissste" etiqueta="Desc. mensual Info o Fovissste ($)" />
<CampoSelect nombre="tipoProducto" etiqueta="Tipo de producto" opciones={TIPOS_PRODUCTO} />
</SectionCard>
<SectionCard
titulo="Identificación oficial (PLD)"
descripcion="Datos de la identificación vigente para los formatos de ley anti-lavado."
>
<CampoTexto nombre="idAutoridadEmisora" etiqueta="Autoridad que la emite" className="sm:col-span-2" />
<CampoTexto nombre="idNumero" etiqueta="Número de identificación" />
<CampoTexto nombre="idVigencia" etiqueta="Vigencia" tipo="date" />
</SectionCard>
<SectionCard titulo="Domicilio fiscal (opcional)" descripcion="Art. 29 y 29-A CFF.">
<CampoTexto nombre="domicilioFiscal.calle" etiqueta="Calle o avenida" className="sm:col-span-2" />
<CampoTexto nombre="domicilioFiscal.numExt" etiqueta="Núm. exterior" />
<CampoTexto nombre="domicilioFiscal.numInt" etiqueta="Núm. interior" />
<CampoTexto nombre="domicilioFiscal.colonia" etiqueta="Colonia" />
<CampoTexto nombre="domicilioFiscal.municipio" etiqueta="Municipio" />
<CampoTexto nombre="domicilioFiscal.estado" etiqueta="Estado" />
<CampoTexto nombre="domicilioFiscal.cp" etiqueta="Código postal" />
<CampoTexto nombre="domicilioFiscal.pais" etiqueta="País" />
</SectionCard>
</div>
<MensajeError nombre="proyectoOtro" />
<Navegacion onAnterior={onAnterior} />
</form>
</FormProvider>
)
}

View File

@@ -0,0 +1,148 @@
import { useState } from 'react'
import type { AltaCliente } from '../types'
import { fechaISOHoy } from '../lib/formatters'
import { ASESOR_DEFAULT, AUTORIDAD_ID_DEFAULT } from '../data/constantes'
import { useDocumentos } from './useDocumentos'
const datosGeneralesVacios = (): AltaCliente['datosGenerales'] => ({
campaniaVenta: '',
fecha: fechaISOHoy(),
nombreCompleto: '',
nacionalidad: 'MEXICANA',
nivelEstudios: '',
curp: '',
rfc: '',
nss: '',
delegacionMunicipio: '',
direccion: '',
codigoPostal: '',
antiguedadDomicilio: '',
fechaNacimiento: '',
lugarNacimiento: '',
estadoCivil: '',
regimenBienes: '',
peso: '',
estatura: '',
email: '',
telCasa: '',
celular: '',
viviendaAnios: '',
tipoVivienda: '',
tipoCompra: '',
actividadOcupacion: '',
})
const empleoVacio = (): AltaCliente['empleo'] => ({
tipoEmpleo: '',
tipoEconomia: '',
compania: '',
antiguedadAnios: '',
antiguedadMeses: '',
direccion: '',
colonia: '',
numero: '',
codigoPostal: '',
puesto: '',
telefono: '',
ext: '',
delegacion: '',
})
const economicosVacios = (): AltaCliente['economicos'] => ({
ingresosMensuales: 0,
otrosIngresos: 0,
gastosLuz: 0,
gastosOtros: 0,
dependientes: { personas: '', parentesco: '', edad: '' },
})
const estadoInicial = (): Omit<AltaCliente, 'documentos'> => ({
datosGenerales: datosGeneralesVacios(),
empleo: empleoVacio(),
economicos: economicosVacios(),
coacreditado: {
tieneCoacreditado: false,
generales: datosGeneralesVacios(),
empleo: empleoVacio(),
economicos: economicosVacios(),
},
referencias: [
{ nombre: '', telefono: '', parentesco: '', direccion: '' },
{ nombre: '', telefono: '', parentesco: '', direccion: '' },
{ nombre: '', telefono: '', parentesco: '', direccion: '' },
{ nombre: '', telefono: '', parentesco: '', direccion: '' },
],
viviendaCredito: {
proyecto: '',
proyectoOtro: '',
modelo: '',
manzana: '',
lote: '',
ubicacion: '',
superficieM2: '',
valorVenta: '',
enganche: '',
equipamientoAdicional: '',
fechaPagoEnganche: '',
firmaContrato: '',
tiposCredito: [],
montoCreditoInfoFovi: '',
ahorroVoluntarioSubcuenta: '',
tipoProducto: '',
descuentoMensualInfoFovissste: '',
idAutoridadEmisora: AUTORIDAD_ID_DEFAULT,
idNumero: '',
idVigencia: '',
domicilioFiscal: {
calle: '',
numExt: '',
numInt: '',
colonia: '',
municipio: '',
estado: '',
cp: '',
pais: 'MEXICO',
},
},
firma: { firmaDataUrl: '', asesorNombre: ASESOR_DEFAULT },
})
export const PASOS = [
'Datos generales',
'Empleo',
'Económicos',
'Co-acreditado',
'Referencias',
'Vivienda y crédito',
'Documentos',
'Firmas y generar',
] as const
/** Estado global del wizard de alta de clientes */
export function useAltaCliente() {
const [paso, setPaso] = useState(0)
const [datos, setDatos] = useState(estadoInicial)
const { documentos, agregarDocumento, quitarDocumento } = useDocumentos()
const alta: AltaCliente = { ...datos, documentos }
const actualizar = <K extends keyof typeof datos>(seccion: K, valores: (typeof datos)[K]) => {
setDatos((prev) => ({ ...prev, [seccion]: valores }))
}
const siguiente = () => setPaso((p) => Math.min(p + 1, PASOS.length - 1))
const anterior = () => setPaso((p) => Math.max(p - 1, 0))
const irAPaso = (p: number) => setPaso(p)
return {
paso,
siguiente,
anterior,
irAPaso,
datos,
alta,
actualizar,
agregarDocumento,
quitarDocumento,
}
}

View File

@@ -0,0 +1,17 @@
import { useCallback, useState } from 'react'
import type { DocumentoCargado, SlotDocumento } from '../types'
/** Maneja los documentos del expediente en memoria (uno por slot) */
export function useDocumentos() {
const [documentos, setDocumentos] = useState<DocumentoCargado[]>([])
const agregarDocumento = useCallback((doc: DocumentoCargado) => {
setDocumentos((prev) => [...prev.filter((d) => d.slot !== doc.slot), doc])
}, [])
const quitarDocumento = useCallback((slot: SlotDocumento) => {
setDocumentos((prev) => prev.filter((d) => d.slot !== slot))
}, [])
return { documentos, agregarDocumento, quitarDocumento }
}

View File

@@ -0,0 +1,23 @@
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--color-accent: #1B4332;
--radius-sm: 0;
--radius-md: 0;
--radius-lg: 0;
--radius-xl: 0;
--radius-2xl: 0;
--radius-3xl: 0;
}
@layer base {
body {
@apply bg-white text-neutral-900 antialiased;
}
:focus-visible {
outline: 2px solid #1b4332;
outline-offset: 2px;
}
}

View File

@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest'
import { calcularTotalIngresos, calcularGastosTotal } from './calculations'
describe('calcularTotalIngresos', () => {
it('suma ingresos mensuales y otros ingresos', () => {
expect(calcularTotalIngresos({ ingresosMensuales: 54988, otrosIngresos: 0 })).toBe(54988)
expect(calcularTotalIngresos({ ingresosMensuales: 20000, otrosIngresos: 5000 })).toBe(25000)
})
it('devuelve 0 cuando ambos son 0', () => {
expect(calcularTotalIngresos({ ingresosMensuales: 0, otrosIngresos: 0 })).toBe(0)
})
})
describe('calcularGastosTotal', () => {
it('suma gastos de luz y otros gastos', () => {
expect(calcularGastosTotal({ gastosLuz: 500, gastosOtros: 5000 })).toBe(5500)
})
it('devuelve 0 cuando ambos son 0', () => {
expect(calcularGastosTotal({ gastosLuz: 0, gastosOtros: 0 })).toBe(0)
})
})

View File

@@ -0,0 +1,11 @@
import type { DatosEconomicos } from '../types'
/** Total de ingresos: ingresos mensuales + otros ingresos */
export function calcularTotalIngresos(economicos: Pick<DatosEconomicos, 'ingresosMensuales' | 'otrosIngresos'>): number {
return economicos.ingresosMensuales + economicos.otrosIngresos
}
/** Total de gastos aproximados: luz + otros */
export function calcularGastosTotal(economicos: Pick<DatosEconomicos, 'gastosLuz' | 'gastosOtros'>): number {
return economicos.gastosLuz + economicos.gastosOtros
}

View File

@@ -0,0 +1,109 @@
import { describe, it, expect } from 'vitest'
import {
validarCurp,
extraerDeCurp,
compararConDatos,
digitoVerificadorCurp,
ENTIDADES_CURP,
} from './curp'
// Fixture real del formato PDF de alta (reference/alta-clientes/pdf_formatos.txt)
const CURP_FIXTURE = 'TECP891124HBCLHS07'
/** Construye una CURP de prueba con dígito verificador calculado por el propio algoritmo */
function construirCurp(base17: string): string {
return `${base17}${digitoVerificadorCurp(base17)}`
}
describe('validarCurp', () => {
it('acepta el fixture real TECP891124HBCLHS07 (formato y dígito)', () => {
expect(validarCurp(CURP_FIXTURE)).toEqual({ valida: true })
})
it('acepta CURPs construidas con el propio algoritmo', () => {
const bases = [
'TECP891124HBCLHS0',
'GOML900515HDFRRN0',
'LOQA050312MCLPMA0',
'HEMA000101HDFXXX0',
'XAXX010101MNEXXXA', // genérica sin homoclave real, solo para dígito
]
for (const base of bases) {
expect(validarCurp(construirCurp(base)).valida).toBe(true)
}
})
it('rechaza un dígito verificador alterado', () => {
const buena = construirCurp('TECP891124HBCLHS0')
const malDigito = `${buena.slice(0, 17)}${(Number(buena[17]) + 1) % 10}`
expect(validarCurp(malDigito)).toEqual({ valida: false, motivo: 'digito' })
})
it('rechaza formatos inválidos', () => {
expect(validarCurp('TECP891124')).toEqual({ valida: false, motivo: 'formato' })
expect(validarCurp('TECP891124XBCLHS07')).toEqual({ valida: false, motivo: 'formato' })
expect(validarCurp('')).toEqual({ valida: false, motivo: 'formato' })
})
it('acepta minúsculas y espacios alrededor', () => {
expect(validarCurp(' tecp891124hbclhs07 ').valida).toBe(true)
})
})
describe('extraerDeCurp', () => {
it('parsea fecha, sexo y entidad del fixture real', () => {
const datos = extraerDeCurp(CURP_FIXTURE)
expect(datos).not.toBeNull()
expect(datos?.fechaNacimiento).toBe('1989-11-24')
expect(datos?.sexo).toBe('H')
expect(datos?.entidad).toBe('BC')
expect(datos?.entidadNombre).toBe('BAJA CALIFORNIA')
})
it('aplica la regla de siglo (YY <= año actual => 2000s)', () => {
const datos = extraerDeCurp(construirCurp('GOML050515HDFRRN0'))
expect(datos?.fechaNacimiento).toBe('2005-05-15')
})
it('devuelve null con formato inválido', () => {
expect(extraerDeCurp('NO-ES-CURP')).toBeNull()
})
it('el mapa tiene las 33 entidades', () => {
expect(Object.keys(ENTIDADES_CURP)).toHaveLength(33)
expect(ENTIDADES_CURP.NE).toBe('NACIDO EN EL EXTRANJERO')
})
})
describe('compararConDatos', () => {
it('sin advertencias cuando todo coincide', () => {
expect(
compararConDatos(CURP_FIXTURE, {
fechaNacimiento: '1989-11-24',
lugarNacimiento: 'Tijuana, Baja California',
}),
).toEqual([])
})
it('advierte cuando la fecha no coincide', () => {
const avisos = compararConDatos(CURP_FIXTURE, { fechaNacimiento: '1990-01-01' })
expect(avisos).toHaveLength(1)
expect(avisos[0]).toContain('fecha de nacimiento')
})
it('advierte cuando el lugar no coincide con la entidad', () => {
const avisos = compararConDatos(CURP_FIXTURE, { lugarNacimiento: 'Guadalajara, Jalisco' })
expect(avisos).toHaveLength(1)
expect(avisos[0]).toContain('lugar de nacimiento')
expect(avisos[0]).toContain('BAJA CALIFORNIA')
})
it('advierte dígito verificador incorrecto', () => {
const avisos = compararConDatos('TECP891124HBCLHS09', {})
expect(avisos.some((a) => a.includes('dígito verificador'))).toBe(true)
})
it('no advierte nada con CURP vacía', () => {
expect(compararConDatos('', { fechaNacimiento: '1989-11-24' })).toEqual([])
})
})

View File

@@ -0,0 +1,148 @@
// Validación y extracción de datos de la CURP (Clave Única de Registro de Población)
// Algoritmo oficial del dígito verificador: alfabeto RENAPO, pesos 18..2,
// suma mod 10 y complemento a 10.
export const REGEX_CURP_FORMATO = /^[A-Z]{4}\d{6}[HM][A-Z]{5}[0-9A-Z]\d$/
const ALFABETO = '0123456789ABCDEFGHIJKLMNÑOPQRSTUVWXYZ'
export const ENTIDADES_CURP: Record<string, string> = {
AS: 'AGUASCALIENTES',
BC: 'BAJA CALIFORNIA',
BS: 'BAJA CALIFORNIA SUR',
CC: 'CAMPECHE',
CL: 'COAHUILA',
CM: 'COLIMA',
CS: 'CHIAPAS',
CH: 'CHIHUAHUA',
DF: 'CIUDAD DE MEXICO',
DG: 'DURANGO',
GT: 'GUANAJUATO',
GR: 'GUERRERO',
HG: 'HIDALGO',
JC: 'JALISCO',
MC: 'MEXICO',
MN: 'MICHOACAN',
MS: 'MORELOS',
NT: 'NAYARIT',
NL: 'NUEVO LEON',
OC: 'OAXACA',
PL: 'PUEBLA',
QT: 'QUERETARO',
QR: 'QUINTANA ROO',
SP: 'SAN LUIS POTOSI',
SL: 'SINALOA',
SR: 'SONORA',
TC: 'TABASCO',
TS: 'TAMAULIPAS',
TL: 'TLAXCALA',
VZ: 'VERACRUZ',
YN: 'YUCATAN',
ZS: 'ZACATECAS',
NE: 'NACIDO EN EL EXTRANJERO',
}
/** Quita acentos y pasa a mayúsculas para comparar textos */
function normalizar(texto: string): string {
return texto
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toUpperCase()
.trim()
}
/** Calcula el dígito verificador de los primeros 17 caracteres de la CURP */
export function digitoVerificadorCurp(curp17: string): number {
let suma = 0
for (let i = 0; i < 17; i++) {
const valor = ALFABETO.indexOf(curp17[i])
if (valor === -1) return -1
suma += valor * (18 - i)
}
return (10 - (suma % 10)) % 10
}
export interface ResultadoValidacionCurp {
valida: boolean
/** 'formato' si no cumple el patrón, 'digito' si el verificador no cuadra */
motivo?: 'formato' | 'digito'
}
/** Valida formato (regex) y dígito verificador de la CURP */
export function validarCurp(curp: string): ResultadoValidacionCurp {
const limpia = normalizar(curp)
if (!REGEX_CURP_FORMATO.test(limpia)) return { valida: false, motivo: 'formato' }
const esperado = digitoVerificadorCurp(limpia.slice(0, 17))
if (esperado !== Number(limpia[17])) return { valida: false, motivo: 'digito' }
return { valida: true }
}
export interface DatosCurp {
/** Fecha de nacimiento en formato YYYY-MM-DD */
fechaNacimiento: string
sexo: 'H' | 'M'
/** Código de entidad federativa (posiciones 12-13) */
entidad: string
/** Nombre de la entidad federativa */
entidadNombre: string
}
/**
* Extrae fecha de nacimiento, sexo y entidad de una CURP con formato válido.
* Regla de siglo: YY <= año actual % 100 => 20YY, si no 19YY.
* Devuelve null si el formato no es válido.
*/
export function extraerDeCurp(curp: string): DatosCurp | null {
const limpia = normalizar(curp)
if (!REGEX_CURP_FORMATO.test(limpia)) return null
const yy = Number(limpia.slice(4, 6))
const mm = limpia.slice(6, 8)
const dd = limpia.slice(8, 10)
const anioActual = new Date().getFullYear() % 100
const anio = yy <= anioActual ? 2000 + yy : 1900 + yy
const entidad = limpia.slice(11, 13)
return {
fechaNacimiento: `${anio}-${mm}-${dd}`,
sexo: limpia[10] as 'H' | 'M',
entidad,
entidadNombre: ENTIDADES_CURP[entidad] ?? entidad,
}
}
/**
* Compara la CURP con los datos capturados y devuelve advertencias
* (no bloquean; solo alertan inconsistencias).
*/
export function compararConDatos(
curp: string,
datos: { fechaNacimiento?: string; lugarNacimiento?: string },
): string[] {
const advertencias: string[] = []
const limpia = normalizar(curp)
if (limpia === '') return advertencias
const validacion = validarCurp(limpia)
if (!validacion.valida && validacion.motivo === 'digito') {
advertencias.push('El dígito verificador de la CURP no coincide; verifica la captura')
}
if (!validacion.valida) return advertencias
const extraidos = extraerDeCurp(limpia)
if (!extraidos) return advertencias
const fechaCapturada = (datos.fechaNacimiento ?? '').trim()
if (fechaCapturada !== '' && fechaCapturada !== extraidos.fechaNacimiento) {
advertencias.push(
`La fecha de nacimiento no coincide con la CURP (${extraidos.fechaNacimiento} según CURP)`,
)
}
const lugar = normalizar(datos.lugarNacimiento ?? '')
if (lugar !== '' && !lugar.includes(extraidos.entidadNombre)) {
advertencias.push(
`El lugar de nacimiento no coincide con la entidad de la CURP (${extraidos.entidadNombre})`,
)
}
return advertencias
}

View File

@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest'
import { estadoExpediente } from './expediente'
import type { DocumentoCargado, SlotDocumento } from '../types'
const doc = (slot: SlotDocumento): DocumentoCargado => ({
slot,
nombre: `${slot}.pdf`,
tamano: 1024,
tipoMime: 'application/pdf',
archivo: new File(['x'], `${slot}.pdf`, { type: 'application/pdf' }),
})
const FIRMA = 'data:image/png;base64,iVBORw0KGgo='
describe('estadoExpediente', () => {
it('expediente vacío: 0/6 y todo pendiente', () => {
const e = estadoExpediente([], '')
expect(e.total).toBe(6)
expect(e.completos).toBe(0)
expect(e.completoTodo).toBe(false)
expect(e.items.every((i) => !i.completo)).toBe(true)
})
it('cuenta documentos cargados y firma por separado', () => {
const e = estadoExpediente([doc('INE'), doc('CURP')], FIRMA)
expect(e.completos).toBe(3)
expect(e.items.find((i) => i.clave === 'INE')?.completo).toBe(true)
expect(e.items.find((i) => i.clave === 'CONSTANCIA_RFC')?.completo).toBe(false)
expect(e.items.find((i) => i.clave === 'FIRMA')?.completo).toBe(true)
})
it('expediente completo: 6/6', () => {
const docs = [
doc('INE'),
doc('CONSTANCIA_RFC'),
doc('CURP'),
doc('COMPROBANTE_DOMICILIO'),
doc('ACTA_NACIMIENTO'),
]
const e = estadoExpediente(docs, FIRMA)
expect(e.completos).toBe(6)
expect(e.completoTodo).toBe(true)
})
it('una cadena que no es data:image no cuenta como firma', () => {
expect(estadoExpediente([], 'garabato').items.find((i) => i.clave === 'FIRMA')?.completo).toBe(false)
})
})

View File

@@ -0,0 +1,34 @@
import type { DocumentoCargado } from '../types'
import { SLOTS_DOCUMENTOS } from '../data/constantes'
export interface ItemExpediente {
clave: string
etiqueta: string
completo: boolean
}
export interface EstadoExpediente {
items: ItemExpediente[]
completos: number
total: number
completoTodo: boolean
}
/**
* Estado del expediente en vivo: los 5 documentos del formato + la firma.
* Función pura para poder probarla y reutilizarla en los pasos 7 y 8.
*/
export function estadoExpediente(documentos: DocumentoCargado[], firmaDataUrl: string): EstadoExpediente {
const items: ItemExpediente[] = SLOTS_DOCUMENTOS.map(({ slot, etiqueta }) => ({
clave: slot,
etiqueta,
completo: documentos.some((d) => d.slot === slot),
}))
items.push({
clave: 'FIRMA',
etiqueta: 'Firma del cliente',
completo: firmaDataUrl.startsWith('data:image/'),
})
const completos = items.filter((i) => i.completo).length
return { items, completos, total: items.length, completoTodo: completos === items.length }
}

View File

@@ -0,0 +1,156 @@
import type { AltaCliente } from '../types'
// PNG de 1x1 px para ejercitar la incrustación de firma en los tests
export const FIRMA_PRUEBA =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='
/**
* Fixture con los datos del cliente del PDF de referencia
* (PASCUAL MARTIN TELLEZ CHIGUINDO — pdf_formatos.txt)
*/
export const fixtureAlta: AltaCliente = {
datosGenerales: {
campaniaVenta: '',
fecha: '2026-07-19',
nombreCompleto: 'PASCUAL MARTIN TELLEZ CHIGUINDO',
nacionalidad: 'MEXICANA',
nivelEstudios: 'MAESTRIA',
curp: 'TECP891124HBCLHS07',
rfc: 'TECP8911246B4',
nss: '',
delegacionMunicipio: 'TIJUANA',
direccion: 'RIO EUFRATES EDIF 1 7145 203',
codigoPostal: '22223',
antiguedadDomicilio: '2',
fechaNacimiento: '1989-11-24',
lugarNacimiento: 'TIJUANA',
estadoCivil: 'SOLTERO',
regimenBienes: '',
peso: '87',
estatura: '1.78',
email: 'dantemartinchi@gmail.com',
telCasa: '6646935924',
celular: '6646935924',
viviendaAnios: '2',
tipoVivienda: 'PROPIA',
tipoCompra: 'VIVIR_AHI',
actividadOcupacion: 'AGENTE DEL MINISTERIO PUBLICO FEDERAL',
},
empleo: {
tipoEmpleo: 'ASALARIADO',
tipoEconomia: 'E_MEXICANA',
compania: 'FISCALIA GENERAL DE LA REPUBLICA',
antiguedadAnios: '12',
antiguedadMeses: '10',
direccion: 'ZONA RIO TIJUANA',
colonia: 'ZONA RIO',
numero: '',
codigoPostal: '22230',
puesto: 'AGENTE DE MINISTERIO PUBLICO FEDERAL',
telefono: '6646336847',
ext: '',
delegacion: 'TIJUANA',
},
economicos: {
ingresosMensuales: 54988,
otrosIngresos: 0,
gastosLuz: 500,
gastosOtros: 5000,
dependientes: { personas: '', parentesco: '', edad: '' },
},
coacreditado: {
tieneCoacreditado: false,
generales: {
campaniaVenta: '',
fecha: '2026-07-19',
nombreCompleto: '',
nacionalidad: '',
nivelEstudios: '',
curp: '',
rfc: '',
nss: '',
delegacionMunicipio: '',
direccion: '',
codigoPostal: '',
antiguedadDomicilio: '',
fechaNacimiento: '',
lugarNacimiento: '',
estadoCivil: '',
regimenBienes: '',
peso: '',
estatura: '',
email: '',
telCasa: '',
celular: '',
viviendaAnios: '',
tipoVivienda: '',
tipoCompra: '',
actividadOcupacion: '',
},
empleo: {
tipoEmpleo: '',
tipoEconomia: '',
compania: '',
antiguedadAnios: '',
antiguedadMeses: '',
direccion: '',
colonia: '',
numero: '',
codigoPostal: '',
puesto: '',
telefono: '',
ext: '',
delegacion: '',
},
economicos: {
ingresosMensuales: 0,
otrosIngresos: 0,
gastosLuz: 0,
gastosOtros: 0,
dependientes: { personas: '', parentesco: '', edad: '' },
},
},
referencias: [
{ nombre: 'JOSE ARNOLDO MENDOZA', telefono: '6641887799', parentesco: 'AMIGO', direccion: 'ALTAMIRA' },
{ nombre: 'JAIME JOHAN LARA', telefono: '6644704362', parentesco: 'AMIGO', direccion: 'CAÑON OASIS' },
{ nombre: 'FANNY VANESSA CARDENAS', telefono: '6634381839', parentesco: 'AMIGO', direccion: 'VISTA LAGO' },
{ nombre: 'CYNTHIA ANAHI GARCIA', telefono: '6634303303', parentesco: 'AMIGO', direccion: 'ZONA RIO' },
],
viviendaCredito: {
proyecto: 'SENDA',
proyectoOtro: '',
modelo: 'LAUREL',
manzana: '11',
lote: '11',
ubicacion: '409-1-11-11',
superficieM2: '',
valorVenta: '2700000',
enganche: '0',
equipamientoAdicional: '',
fechaPagoEnganche: '',
firmaContrato: '',
tiposCredito: ['BANCARIO'],
montoCreditoInfoFovi: '',
ahorroVoluntarioSubcuenta: '',
tipoProducto: '',
descuentoMensualInfoFovissste: '',
idAutoridadEmisora: 'INSTITUTO NACIONAL ELECTORAL',
idNumero: 'IDMEX2311049617',
idVigencia: '2032-12-31',
domicilioFiscal: {
calle: 'FRAY PEDRO DE GANTE',
numExt: '97',
numInt: '0',
colonia: 'TIJUANA OTAY',
municipio: 'TIJUANA',
estado: 'BAJA CALIFORNIA',
cp: '22508',
pais: 'MEXICO',
},
},
documentos: [],
firma: {
firmaDataUrl: FIRMA_PRUEBA,
asesorNombre: 'ESMERALDA BECERRA DOMINGUEZ',
},
}

View File

@@ -0,0 +1,48 @@
const formatoMXN = new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
})
/** Formato monetario MXN, p. ej. $2,700,000.00 */
export function formatoMoneda(valor: number): string {
return formatoMXN.format(valor)
}
/** Fecha en formato largo es-MX: "20 de agosto de 2026" */
export function formatoFechaLarga(fecha: Date): string {
return fecha.toLocaleDateString('es-MX', { day: 'numeric', month: 'long', year: 'numeric' })
}
/** ISO 'YYYY-MM-DD' a fecha legible corta: "24/11/1989" */
export function formatoFechaCorta(iso: string): string {
if (!iso) return ''
return new Date(`${iso}T00:00:00`).toLocaleDateString('es-MX', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
})
}
/** Fecha ISO del día (para nombre de archivo): 'YYYY-MM-DD' */
export function fechaISOHoy(fecha: Date = new Date()): string {
const y = fecha.getFullYear()
const m = String(fecha.getMonth() + 1).padStart(2, '0')
const d = String(fecha.getDate()).padStart(2, '0')
return `${y}-${m}-${d}`
}
/** Normaliza un nombre para usarlo en nombre de archivo: sin acentos ni espacios */
export function normalizarNombreArchivo(nombre: string): string {
return nombre
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.replace(/\s+/g, '_')
.replace(/[^A-Za-z0-9_]/g, '')
.toUpperCase()
}
/** Construye el nombre de archivo PDF: {tipo}_{nombreCliente}_{fechaISO}.pdf */
export function nombreArchivoPdf(tipo: string, nombreCliente: string, fecha: Date = new Date()): string {
return `${tipo}_${normalizarNombreArchivo(nombreCliente)}_${fechaISOHoy(fecha)}.pdf`
}

View File

@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest'
import { parsearTextoIne } from './ocrIne'
// Texto OCR de ejemplo de una credencial INE (frente)
const TEXTO_OCR = `
INSTITUTO NACIONAL ELECTORAL
CREDENCIAL PARA VOTAR
NOMBRE
TELLEZ CHIGUINDO
PASCUAL MARTIN
DOMICILIO
AV REVOLUCION 123
TIJUANA BC 22000
CLAVE DE ELECTOR TLCHPS89112402H400
CURP TECP891124HBCLHS07
FECHA DE NACIMIENTO 24/11/1989
SEXO H
VIGENCIA 2020-2030
`
describe('parsearTextoIne', () => {
it('extrae CURP y deriva fecha y sexo', () => {
const s = parsearTextoIne(TEXTO_OCR)
expect(s.curp).toBe('TECP891124HBCLHS07')
expect(s.fechaNacimiento).toBe('1989-11-24')
expect(s.sexo).toBe('H')
})
it('extrae el nombre de las líneas tras NOMBRE', () => {
const s = parsearTextoIne(TEXTO_OCR)
expect(s.nombre).toBe('TELLEZ CHIGUINDO PASCUAL MARTIN')
})
it('sin CURP no deriva fecha ni sexo', () => {
const s = parsearTextoIne('NOMBRE\nPEREZ LOPEZ\nJUAN\nDOMICILIO')
expect(s.curp).toBeUndefined()
expect(s.fechaNacimiento).toBeUndefined()
expect(s.sexo).toBeUndefined()
expect(s.nombre).toBe('PEREZ LOPEZ JUAN')
})
it('texto sin nada útil devuelve sugerencias vacías', () => {
expect(parsearTextoIne('!!! ### 123')).toEqual({})
})
it('tolerante a minúsculas del OCR', () => {
const s = parsearTextoIne('nombre\ntellez chiguindo\npascual martin\ncurp tecp891124hbclhs07')
expect(s.curp).toBe('TECP891124HBCLHS07')
expect(s.nombre).toBe('TELLEZ CHIGUINDO PASCUAL MARTIN')
})
})

View File

@@ -0,0 +1,81 @@
// Parser del texto OCR de una credencial INE: extrae sugerencias
// (CURP, nombre, y fecha/sexo derivados de la CURP) sin tocar el formulario.
import { extraerDeCurp } from './curp'
export interface SugerenciasIne {
curp?: string
nombre?: string
/** YYYY-MM-DD, derivada de la CURP detectada */
fechaNacimiento?: string
sexo?: 'H' | 'M'
}
const REGEX_CURP_TEXTO = /[A-ZÑ]{4}\d{6}[HM][A-Z]{5}[0-9A-Z]\d/
// Etiquetas típicas de la credencial que delimitan el bloque del nombre
const ETIQUETAS_CORTE = [
'DOMICILIO',
'FECHA DE NACIMIENTO',
'CLAVE DE ELECTOR',
'CURP',
'ESTADO',
'MUNICIPIO',
'LOCALIDAD',
'SEXO',
'VIGENCIA',
'EMISION',
'ANO DE REGISTRO',
]
function esEtiquetaCorte(linea: string): boolean {
return ETIQUETAS_CORTE.some((e) => linea.startsWith(e))
}
/**
* Heurística de nombre: líneas en mayúsculas que siguen a la línea "NOMBRE",
* hasta topar con otra etiqueta de la credencial (máx 2 líneas).
*/
function extraerNombre(lineas: string[]): string | undefined {
const idx = lineas.findIndex((l) => l === 'NOMBRE' || l.startsWith('NOMBRE '))
if (idx === -1) return undefined
const partes: string[] = []
// Si la línea "NOMBRE" ya trae texto detrás, tómalo como primera parte
const enMismaLinea = lineas[idx].replace(/^NOMBRE\s*/, '').trim()
if (enMismaLinea !== '') partes.push(enMismaLinea)
for (let i = idx + 1; i < lineas.length && partes.length < 2; i++) {
const linea = lineas[i]
if (esEtiquetaCorte(linea)) break
if (!/^[A-ZÑÁÉÍÓÚ' -]{3,}$/.test(linea)) break
partes.push(linea)
}
const nombre = partes.join(' ').replace(/\s+/g, ' ').trim()
return nombre === '' ? undefined : nombre
}
/** Convierte el texto OCR crudo en sugerencias para el formulario */
export function parsearTextoIne(texto: string): SugerenciasIne {
const lineas = texto
.toUpperCase()
.split(/\r?\n/)
.map((l) => l.trim())
.filter((l) => l !== '')
const sugerencias: SugerenciasIne = {}
const textoPlano = lineas.join(' ')
const curp = textoPlano.match(REGEX_CURP_TEXTO)?.[0]
if (curp) {
sugerencias.curp = curp
const derivados = extraerDeCurp(curp)
if (derivados) {
sugerencias.fechaNacimiento = derivados.fechaNacimiento
sugerencias.sexo = derivados.sexo
}
}
const nombre = extraerNombre(lineas)
if (nombre) sugerencias.nombre = nombre
return sugerencias
}

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)
}

View File

@@ -0,0 +1,173 @@
import { describe, it, expect } from 'vitest'
import {
datosGeneralesSchema,
coacreditadoSchema,
referenciasSchema,
REGEX_CURP,
REGEX_RFC,
} from './schemas'
import type { DatosGenerales } from '../types'
// Datos válidos base con el fixture del cliente del PDF de referencia
const generalesValidos: DatosGenerales = {
campaniaVenta: '',
fecha: '2026-07-19',
nombreCompleto: 'PASCUAL MARTIN TELLEZ CHIGUINDO',
nacionalidad: 'MEXICANA',
nivelEstudios: 'MAESTRIA',
curp: 'TECP891124HBCLHS07',
rfc: 'TECP8911246B4',
nss: '',
delegacionMunicipio: 'TIJUANA',
direccion: 'RIO EUFRATES EDIF 1 7145 203',
codigoPostal: '22223',
antiguedadDomicilio: '2',
fechaNacimiento: '1989-11-24',
lugarNacimiento: 'TIJUANA',
estadoCivil: 'SOLTERO',
regimenBienes: '',
peso: '87',
estatura: '1.78',
email: 'dantemartinchi@gmail.com',
telCasa: '',
celular: '6646935924',
viviendaAnios: '2',
tipoVivienda: 'PROPIA',
tipoCompra: 'VIVIR_AHI',
actividadOcupacion: 'AGENTE DEL MINISTERIO PUBLICO FEDERAL',
}
describe('regex de identificadores', () => {
it('acepta CURP válida', () => {
expect(REGEX_CURP.test('TECP891124HBCLHS07')).toBe(true)
})
it('rechaza CURP inválida', () => {
expect(REGEX_CURP.test('TECP891124HBCLHS0')).toBe(false) // corta
expect(REGEX_CURP.test('tecp891124hbclhs07')).toBe(false) // minúsculas
expect(REGEX_CURP.test('TECP89112XHBCLHS07')).toBe(false) // fecha inválida
})
it('acepta RFC válido', () => {
expect(REGEX_RFC.test('TECP8911246B4')).toBe(true)
})
it('rechaza RFC inválido', () => {
expect(REGEX_RFC.test('TECP8911246B')).toBe(false) // corto
expect(REGEX_RFC.test('TECP891124!B4')).toBe(false)
expect(REGEX_RFC.test('tecp8911246b4')).toBe(false)
})
})
describe('datosGeneralesSchema', () => {
it('acepta los datos del fixture', () => {
expect(datosGeneralesSchema.safeParse(generalesValidos).success).toBe(true)
})
it('rechaza CURP inválida', () => {
const r = datosGeneralesSchema.safeParse({ ...generalesValidos, curp: 'XXXX000000XXXXXXX0' })
expect(r.success).toBe(false)
})
it('rechaza RFC inválido', () => {
const r = datosGeneralesSchema.safeParse({ ...generalesValidos, rfc: '123' })
expect(r.success).toBe(false)
})
it('rechaza email inválido', () => {
const r = datosGeneralesSchema.safeParse({ ...generalesValidos, email: 'no-es-correo' })
expect(r.success).toBe(false)
})
it('rechaza código postal que no tenga 5 dígitos', () => {
const r = datosGeneralesSchema.safeParse({ ...generalesValidos, codigoPostal: '2223' })
expect(r.success).toBe(false)
})
it('exige régimen de bienes solo cuando el estado civil es CASADO', () => {
const casado = datosGeneralesSchema.safeParse({ ...generalesValidos, estadoCivil: 'CASADO' })
expect(casado.success).toBe(false)
const casadoConRegimen = datosGeneralesSchema.safeParse({
...generalesValidos,
estadoCivil: 'CASADO',
regimenBienes: 'SEPARACION_BIENES',
})
expect(casadoConRegimen.success).toBe(true)
})
})
describe('coacreditadoSchema', () => {
const base = {
generales: { ...generalesValidos, nombreCompleto: '', curp: '', rfc: '', email: '', fechaNacimiento: '', celular: '' },
empleo: {
tipoEmpleo: '' as const,
tipoEconomia: '' as const,
compania: '',
antiguedadAnios: '',
antiguedadMeses: '',
direccion: '',
colonia: '',
numero: '',
codigoPostal: '',
puesto: '',
telefono: '',
ext: '',
delegacion: '',
},
economicos: {
ingresosMensuales: 0,
otrosIngresos: 0,
gastosLuz: 0,
gastosOtros: 0,
dependientes: { personas: '', parentesco: '', edad: '' },
},
}
it('no exige datos del co-acreditado cuando el toggle está apagado', () => {
const r = coacreditadoSchema.safeParse({ ...base, tieneCoacreditado: false })
expect(r.success).toBe(true)
})
it('exige el nombre del co-acreditado cuando el toggle está activo', () => {
const r = coacreditadoSchema.safeParse({ ...base, tieneCoacreditado: true })
expect(r.success).toBe(false)
})
it('acepta co-acreditado con nombre cuando el toggle está activo', () => {
const r = coacreditadoSchema.safeParse({
...base,
tieneCoacreditado: true,
generales: { ...base.generales, nombreCompleto: 'MARIA LOPEZ PEREZ' },
})
expect(r.success).toBe(true)
})
})
describe('referenciasSchema', () => {
const vacia = { nombre: '', telefono: '', parentesco: '', direccion: '' }
it('rechaza cuando ninguna referencia tiene nombre y teléfono', () => {
const r = referenciasSchema.safeParse([vacia, vacia, vacia, vacia])
expect(r.success).toBe(false)
})
it('rechaza referencia con nombre pero sin teléfono', () => {
const r = referenciasSchema.safeParse([
{ ...vacia, nombre: 'JOSE ARNOLDO MENDOZA' },
vacia,
vacia,
vacia,
])
expect(r.success).toBe(false)
})
it('acepta con al menos 1 referencia válida', () => {
const r = referenciasSchema.safeParse([
{ nombre: 'JOSE ARNOLDO MENDOZA', telefono: '6641887799', parentesco: 'AMIGO', direccion: 'ALTAMIRA' },
vacia,
vacia,
vacia,
])
expect(r.success).toBe(true)
})
})

View File

@@ -0,0 +1,219 @@
import { z } from 'zod'
export const REGEX_CURP = /^[A-Z]{4}\d{6}[HM][A-Z]{5}[0-9A-Z]\d$/
export const REGEX_RFC = /^[A-ZÑ&]{4}\d{6}[0-9A-Z]{3}$/
export const REGEX_CP = /^\d{5}$/
// Texto opcional: cadena siempre presente (vacía si no se captura)
const textoOpcional = z.string().trim().max(200)
const curpSchema = z
.string()
.trim()
.toUpperCase()
.regex(REGEX_CURP, 'CURP inválida (18 caracteres, formato oficial)')
const rfcSchema = z
.string()
.trim()
.toUpperCase()
.regex(REGEX_RFC, 'RFC inválido (13 caracteres, formato persona física)')
const cpOpcional = z
.string()
.trim()
.refine((v) => v === '' || REGEX_CP.test(v), 'Código postal de 5 dígitos')
const curpOpcional = z
.string()
.trim()
.toUpperCase()
.refine((v) => v === '' || REGEX_CURP.test(v), 'CURP inválida')
const rfcOpcional = z
.string()
.trim()
.toUpperCase()
.refine((v) => v === '' || REGEX_RFC.test(v), 'RFC inválido')
const emailOpcional = z
.string()
.trim()
.refine((v) => v === '' || z.string().email().safeParse(v).success, 'Correo electrónico inválido')
export const datosGeneralesSchema = z
.object({
campaniaVenta: textoOpcional,
fecha: z.string().min(1, 'La fecha es requerida'),
nombreCompleto: z.string().trim().min(3, 'El nombre completo es requerido'),
nacionalidad: textoOpcional,
nivelEstudios: textoOpcional,
curp: curpSchema,
rfc: rfcSchema,
nss: textoOpcional,
delegacionMunicipio: textoOpcional,
direccion: textoOpcional,
codigoPostal: cpOpcional,
antiguedadDomicilio: textoOpcional,
fechaNacimiento: z.string().min(1, 'La fecha de nacimiento es requerida'),
lugarNacimiento: textoOpcional,
estadoCivil: z.enum(['SOLTERO', 'CASADO', 'UNION_LIBRE', 'DIVORCIADO', 'VIUDO', '']),
regimenBienes: z.enum(['SOCIEDAD_CONYUGAL', 'SEPARACION_BIENES', '']),
peso: textoOpcional,
estatura: textoOpcional,
email: z.string().trim().email('Correo electrónico inválido'),
telCasa: textoOpcional,
celular: z.string().trim().min(8, 'El celular es requerido'),
viviendaAnios: textoOpcional,
tipoVivienda: z.enum(['PRESTADA', 'RENTADA', 'FAMILIAR', 'PROPIA', '']),
tipoCompra: z.enum(['INVERSION', 'VIVIR_AHI', '']),
actividadOcupacion: textoOpcional,
})
.superRefine((datos, ctx) => {
// Régimen de bienes solo aplica (y se exige) cuando el estado civil es CASADO
if (datos.estadoCivil === 'CASADO' && datos.regimenBienes === '') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['regimenBienes'],
message: 'Selecciona el régimen de bienes',
})
}
})
export const empleoSchema = z.object({
tipoEmpleo: z.enum(['ASALARIADO', 'INDEPENDIENTE', '']),
tipoEconomia: z.enum(['E_MEXICANA', 'E_AMERICANA', 'MIXTO', '']),
compania: textoOpcional,
antiguedadAnios: textoOpcional,
antiguedadMeses: textoOpcional,
direccion: textoOpcional,
colonia: textoOpcional,
numero: textoOpcional,
codigoPostal: cpOpcional,
puesto: textoOpcional,
telefono: textoOpcional,
ext: textoOpcional,
delegacion: textoOpcional,
})
const numeroNoNegativo = z.number({ invalid_type_error: 'Ingresa un número' }).min(0, 'No puede ser negativo')
export const economicosSchema = z.object({
ingresosMensuales: numeroNoNegativo,
otrosIngresos: numeroNoNegativo,
gastosLuz: numeroNoNegativo,
gastosOtros: numeroNoNegativo,
dependientes: z.object({
personas: textoOpcional,
parentesco: textoOpcional,
edad: textoOpcional,
}),
})
// Datos generales del co-acreditado: el nombre se exige vía superRefine solo
// cuando el toggle está activo; el resto se valida solo si se captura
const coacreditadoGeneralesSchema = datosGeneralesSchema.innerType().extend({
nombreCompleto: z.string().trim().max(200),
curp: curpOpcional,
rfc: rfcOpcional,
email: emailOpcional,
fechaNacimiento: textoOpcional,
celular: textoOpcional,
})
export const coacreditadoSchema = z
.object({
tieneCoacreditado: z.boolean(),
generales: coacreditadoGeneralesSchema,
empleo: empleoSchema,
economicos: economicosSchema,
})
.superRefine((datos, ctx) => {
// Los datos del co-acreditado solo se exigen si el toggle está activo
if (datos.tieneCoacreditado && datos.generales.nombreCompleto.trim().length < 3) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['generales', 'nombreCompleto'],
message: 'El nombre del co-acreditado es requerido',
})
}
})
const referenciaSchema = z.object({
nombre: textoOpcional,
telefono: textoOpcional,
parentesco: textoOpcional,
direccion: textoOpcional,
})
export const referenciasSchema = z
.array(referenciaSchema)
.length(4, 'Se capturan 4 referencias')
.refine(
(refs) => refs.some((r) => r.nombre.trim() !== '' && r.telefono.trim() !== ''),
'Captura al menos 1 referencia con nombre y teléfono',
)
export const domicilioFiscalSchema = z.object({
calle: textoOpcional,
numExt: textoOpcional,
numInt: textoOpcional,
colonia: textoOpcional,
municipio: textoOpcional,
estado: textoOpcional,
cp: cpOpcional,
pais: textoOpcional,
})
export const viviendaCreditoSchema = z
.object({
proyecto: z.string().min(1, 'Selecciona el proyecto'),
proyectoOtro: textoOpcional,
modelo: textoOpcional,
manzana: textoOpcional,
lote: textoOpcional,
ubicacion: textoOpcional,
superficieM2: textoOpcional,
valorVenta: textoOpcional,
enganche: textoOpcional,
equipamientoAdicional: textoOpcional,
fechaPagoEnganche: textoOpcional,
firmaContrato: textoOpcional,
tiposCredito: z.array(
z.enum([
'INFONAVIT',
'APOYO_INFONAVIT',
'COFINAVIT',
'SEGUNDO_CREDITO',
'CONTADO',
'FOVISSSTE',
'COFI_FOVISSSTE',
'BANCARIO',
]),
),
montoCreditoInfoFovi: textoOpcional,
ahorroVoluntarioSubcuenta: textoOpcional,
tipoProducto: z.enum(['PESOS_FIJOS', 'PESOS_CRECIENTES', 'VSM', '']),
descuentoMensualInfoFovissste: textoOpcional,
idAutoridadEmisora: textoOpcional,
idNumero: textoOpcional,
idVigencia: textoOpcional,
domicilioFiscal: domicilioFiscalSchema,
})
.superRefine((datos, ctx) => {
if (datos.proyecto === 'OTRO' && datos.proyectoOtro.trim() === '') {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['proyectoOtro'],
message: 'Escribe el nombre del proyecto',
})
}
})
export const firmaSchema = z.object({
firmaDataUrl: z
.string()
.min(1, 'La firma del cliente es requerida')
.startsWith('data:image/', 'La firma debe ser una imagen'),
asesorNombre: z.string().trim().min(3, 'El nombre del asesor es requerido'),
})

View File

@@ -0,0 +1,14 @@
/**
* Genera un UUID v4. crypto.randomUUID solo existe en contextos seguros
* (HTTPS o localhost); al servir por IP en HTTP hay que usar el fallback.
*/
export function uuid(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID()
}
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0
const v = c === 'x' ? r : (r & 0x3) | 0x8
return v.toString(16)
})
}

View File

@@ -0,0 +1,20 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter, Route, Routes } from 'react-router-dom'
import '@fontsource/inter/300.css'
import '@fontsource/inter/400.css'
import '@fontsource/inter/500.css'
import '@fontsource/inter/700.css'
import './index.css'
import AltaClientePage from './pages/AltaClientePage'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<BrowserRouter basename="/client">
<Routes>
<Route path="/" element={<AltaClientePage />} />
<Route path="*" element={<AltaClientePage />} />
</Routes>
</BrowserRouter>
</StrictMode>,
)

View File

@@ -0,0 +1,134 @@
import { useState } from 'react'
import Stepper from '../components/Stepper'
import DatosGeneralesForm from '../forms/DatosGeneralesForm'
import EmpleoForm from '../forms/EmpleoForm'
import EconomicosForm from '../forms/EconomicosForm'
import CoacreditadoForm from '../forms/CoacreditadoForm'
import ReferenciasForm from '../forms/ReferenciasForm'
import ViviendaCreditoForm from '../forms/ViviendaCreditoForm'
import DocumentosForm from '../forms/DocumentosForm'
import FirmasForm from '../forms/FirmasForm'
import { useAltaCliente, PASOS } from '../hooks/useAltaCliente'
import { crearAltaCliente, subirDocumentos } from '../services/api'
import { enviarAMonday } from '../services/monday'
export default function AltaClientePage() {
const { paso, siguiente, anterior, irAPaso, datos, alta, actualizar, agregarDocumento, quitarDocumento } =
useAltaCliente()
const [registro, setRegistro] = useState<string>('')
// Registra el alta (stub API + Monday) al llegar al paso de firmas
const registrarAlta = async () => {
const { id } = await crearAltaCliente(alta)
await subirDocumentos(id, alta)
await enviarAMonday(alta)
setRegistro(id)
}
return (
<div className="min-h-screen bg-white">
<header className="border-b border-neutral-200 px-4 py-6">
<div className="mx-auto flex max-w-6xl items-baseline gap-3">
<span className="text-sm font-bold uppercase tracking-widest">FRASA</span>
<h1 className="text-2xl font-light tracking-wide">Alta de Clientes</h1>
</div>
</header>
<main className="mx-auto max-w-6xl px-4 py-16">
<Stepper pasos={[...PASOS]} pasoActual={paso} onIrAPaso={irAPaso} />
<div className="mt-16">
{paso === 0 && (
<DatosGeneralesForm
valores={datos.datosGenerales}
onSiguiente={(v) => {
actualizar('datosGenerales', v)
siguiente()
}}
/>
)}
{paso === 1 && (
<EmpleoForm
valores={datos.empleo}
onAnterior={anterior}
onSiguiente={(v) => {
actualizar('empleo', v)
siguiente()
}}
/>
)}
{paso === 2 && (
<EconomicosForm
valores={datos.economicos}
onAnterior={anterior}
onSiguiente={(v) => {
actualizar('economicos', v)
siguiente()
}}
/>
)}
{paso === 3 && (
<CoacreditadoForm
valores={datos.coacreditado}
onAnterior={anterior}
onSiguiente={(v) => {
actualizar('coacreditado', v)
siguiente()
}}
/>
)}
{paso === 4 && (
<ReferenciasForm
valores={datos.referencias}
onAnterior={anterior}
onSiguiente={(v) => {
actualizar('referencias', v)
siguiente()
}}
/>
)}
{paso === 5 && (
<ViviendaCreditoForm
valores={datos.viviendaCredito}
onAnterior={anterior}
onSiguiente={(v) => {
actualizar('viviendaCredito', v)
siguiente()
}}
/>
)}
{paso === 6 && (
<DocumentosForm
documentos={alta.documentos}
firmaDataUrl={alta.firma.firmaDataUrl}
onAgregar={agregarDocumento}
onQuitar={quitarDocumento}
onAplicarDatosGenerales={(campos) =>
actualizar('datosGenerales', { ...datos.datosGenerales, ...campos })
}
onAnterior={anterior}
onSiguiente={() => {
void registrarAlta()
siguiente()
}}
/>
)}
{paso === 7 && (
<>
{registro && (
<p className="mb-8 border border-accent bg-white px-4 py-3 text-xs font-bold uppercase tracking-widest text-accent">
Alta registrada (stub API): {registro}
</p>
)}
<FirmasForm
alta={alta}
onCambiarFirma={(f) => actualizar('firma', f)}
onAnterior={anterior}
/>
</>
)}
</div>
</main>
</div>
)
}

View File

@@ -0,0 +1,21 @@
import type { AltaCliente } from '../types'
import { uuid } from '../lib/uuid'
export interface AltaClienteCreada {
id: string
}
/** Registra el alta de cliente en el backend */
export async function crearAltaCliente(payload: AltaCliente): Promise<AltaClienteCreada> {
// TODO: POST /api/clientes
await new Promise((resolve) => setTimeout(resolve, 400))
console.log('[api] crearAltaCliente (stub):', payload.datosGenerales.nombreCompleto)
return { id: uuid() }
}
/** Sube los documentos del expediente del cliente */
export async function subirDocumentos(clienteId: string, payload: AltaCliente): Promise<void> {
// TODO: POST multipart a /api/clientes/:id/documentos
await new Promise((resolve) => setTimeout(resolve, 200))
console.log(`[api] subirDocumentos (stub) cliente=${clienteId}:`, payload.documentos.length, 'archivos')
}

View File

@@ -0,0 +1,12 @@
import type { AltaCliente } from '../types'
/** Envía el alta al tablero de Monday (Contactos + Apartado) */
export async function enviarAMonday(payload: AltaCliente): Promise<void> {
// TODO: integración Monday — crear item en tablero "Contactos" y "Apartado"
// con nombre, teléfono, correo, proyecto y ubicación del cliente
console.log('[monday] enviarAMonday (stub):', {
nombre: payload.datosGenerales.nombreCompleto,
proyecto: payload.viviendaCredito.proyecto,
ubicacion: payload.viviendaCredito.ubicacion,
})
}

View File

@@ -0,0 +1,157 @@
// Tipos del dominio "Alta de Clientes" — basados en el formato Excel ALTA de Frasa Desarrollos
export type EstadoCivil = 'SOLTERO' | 'CASADO' | 'UNION_LIBRE' | 'DIVORCIADO' | 'VIUDO'
export type RegimenBienes = 'SOCIEDAD_CONYUGAL' | 'SEPARACION_BIENES'
export type TipoVivienda = 'PRESTADA' | 'RENTADA' | 'FAMILIAR' | 'PROPIA'
export type TipoCompra = 'INVERSION' | 'VIVIR_AHI'
export type TipoEmpleo = 'ASALARIADO' | 'INDEPENDIENTE'
export type TipoEconomia = 'E_MEXICANA' | 'E_AMERICANA' | 'MIXTO'
export type TipoProducto = 'PESOS_FIJOS' | 'PESOS_CRECIENTES' | 'VSM'
export type TipoCredito =
| 'INFONAVIT'
| 'APOYO_INFONAVIT'
| 'COFINAVIT'
| 'SEGUNDO_CREDITO'
| 'CONTADO'
| 'FOVISSSTE'
| 'COFI_FOVISSSTE'
| 'BANCARIO'
export interface DatosGenerales {
campaniaVenta: string
fecha: string
nombreCompleto: string
nacionalidad: string
nivelEstudios: string
curp: string
rfc: string
nss: string
delegacionMunicipio: string
direccion: string
codigoPostal: string
antiguedadDomicilio: string
fechaNacimiento: string
lugarNacimiento: string
estadoCivil: EstadoCivil | ''
regimenBienes: RegimenBienes | ''
peso: string
estatura: string
email: string
telCasa: string
celular: string
viviendaAnios: string
tipoVivienda: TipoVivienda | ''
tipoCompra: TipoCompra | ''
actividadOcupacion: string
}
export interface DatosEmpleo {
tipoEmpleo: TipoEmpleo | ''
tipoEconomia: TipoEconomia | ''
compania: string
antiguedadAnios: string
antiguedadMeses: string
direccion: string
colonia: string
numero: string
codigoPostal: string
puesto: string
telefono: string
ext: string
delegacion: string
}
export interface Dependientes {
personas: string
parentesco: string
edad: string
}
export interface DatosEconomicos {
ingresosMensuales: number
otrosIngresos: number
gastosLuz: number
gastosOtros: number
dependientes: Dependientes
}
export interface Coacreditado {
tieneCoacreditado: boolean
generales: DatosGenerales
empleo: DatosEmpleo
economicos: DatosEconomicos
}
export interface Referencia {
nombre: string
telefono: string
parentesco: string
direccion: string
}
export interface DomicilioFiscal {
calle: string
numExt: string
numInt: string
colonia: string
municipio: string
estado: string
cp: string
pais: string
}
export interface ViviendaCredito {
proyecto: string
proyectoOtro: string
modelo: string
manzana: string
lote: string
ubicacion: string
superficieM2: string
valorVenta: string
enganche: string
equipamientoAdicional: string
fechaPagoEnganche: string
firmaContrato: string
tiposCredito: TipoCredito[]
montoCreditoInfoFovi: string
ahorroVoluntarioSubcuenta: string
tipoProducto: TipoProducto | ''
descuentoMensualInfoFovissste: string
idAutoridadEmisora: string
idNumero: string
idVigencia: string
domicilioFiscal: DomicilioFiscal
}
export type SlotDocumento =
| 'INE'
| 'CONSTANCIA_RFC'
| 'CURP'
| 'COMPROBANTE_DOMICILIO'
| 'ACTA_NACIMIENTO'
export interface DocumentoCargado {
slot: SlotDocumento
nombre: string
tamano: number
tipoMime: string
archivo: File
}
export interface DatosFirma {
firmaDataUrl: string
asesorNombre: string
}
export interface AltaCliente {
datosGenerales: DatosGenerales
empleo: DatosEmpleo
economicos: DatosEconomicos
coacreditado: Coacreditado
referencias: Referencia[]
viviendaCredito: ViviendaCredito
documentos: DocumentoCargado[]
firma: DatosFirma
}