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:
45
apps/alta-clientes/src/forms/CoacreditadoForm.tsx
Normal file
45
apps/alta-clientes/src/forms/CoacreditadoForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
69
apps/alta-clientes/src/forms/DatosGeneralesCampos.tsx
Normal file
69
apps/alta-clientes/src/forms/DatosGeneralesCampos.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
30
apps/alta-clientes/src/forms/DatosGeneralesForm.tsx
Normal file
30
apps/alta-clientes/src/forms/DatosGeneralesForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
59
apps/alta-clientes/src/forms/DocumentosForm.tsx
Normal file
59
apps/alta-clientes/src/forms/DocumentosForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
51
apps/alta-clientes/src/forms/EconomicosCampos.tsx
Normal file
51
apps/alta-clientes/src/forms/EconomicosCampos.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
apps/alta-clientes/src/forms/EconomicosForm.tsx
Normal file
25
apps/alta-clientes/src/forms/EconomicosForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
30
apps/alta-clientes/src/forms/EmpleoCampos.tsx
Normal file
30
apps/alta-clientes/src/forms/EmpleoCampos.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
25
apps/alta-clientes/src/forms/EmpleoForm.tsx
Normal file
25
apps/alta-clientes/src/forms/EmpleoForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
172
apps/alta-clientes/src/forms/FirmasForm.tsx
Normal file
172
apps/alta-clientes/src/forms/FirmasForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
32
apps/alta-clientes/src/forms/Navegacion.tsx
Normal file
32
apps/alta-clientes/src/forms/Navegacion.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
apps/alta-clientes/src/forms/ReferenciasForm.tsx
Normal file
52
apps/alta-clientes/src/forms/ReferenciasForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
112
apps/alta-clientes/src/forms/ViviendaCreditoForm.tsx
Normal file
112
apps/alta-clientes/src/forms/ViviendaCreditoForm.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user