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