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:
145
apps/alta-clientes/src/components/BotonOcrIne.tsx
Normal file
145
apps/alta-clientes/src/components/BotonOcrIne.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
108
apps/alta-clientes/src/components/Campos.tsx
Normal file
108
apps/alta-clientes/src/components/Campos.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
34
apps/alta-clientes/src/components/ChecklistExpediente.tsx
Normal file
34
apps/alta-clientes/src/components/ChecklistExpediente.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
123
apps/alta-clientes/src/components/FileUploader.tsx
Normal file
123
apps/alta-clientes/src/components/FileUploader.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
18
apps/alta-clientes/src/components/SectionCard.tsx
Normal file
18
apps/alta-clientes/src/components/SectionCard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
48
apps/alta-clientes/src/components/SignaturePad.tsx
Normal file
48
apps/alta-clientes/src/components/SignaturePad.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
40
apps/alta-clientes/src/components/Stepper.tsx
Normal file
40
apps/alta-clientes/src/components/Stepper.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
79
apps/alta-clientes/src/data/constantes.ts
Normal file
79
apps/alta-clientes/src/data/constantes.ts
Normal 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'
|
||||
148
apps/alta-clientes/src/data/textosLegales.ts
Normal file
148
apps/alta-clientes/src/data/textosLegales.ts
Normal 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'
|
||||
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>
|
||||
)
|
||||
}
|
||||
148
apps/alta-clientes/src/hooks/useAltaCliente.ts
Normal file
148
apps/alta-clientes/src/hooks/useAltaCliente.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
17
apps/alta-clientes/src/hooks/useDocumentos.ts
Normal file
17
apps/alta-clientes/src/hooks/useDocumentos.ts
Normal 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 }
|
||||
}
|
||||
23
apps/alta-clientes/src/index.css
Normal file
23
apps/alta-clientes/src/index.css
Normal 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;
|
||||
}
|
||||
}
|
||||
23
apps/alta-clientes/src/lib/calculations.test.ts
Normal file
23
apps/alta-clientes/src/lib/calculations.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
11
apps/alta-clientes/src/lib/calculations.ts
Normal file
11
apps/alta-clientes/src/lib/calculations.ts
Normal 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
|
||||
}
|
||||
109
apps/alta-clientes/src/lib/curp.test.ts
Normal file
109
apps/alta-clientes/src/lib/curp.test.ts
Normal 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([])
|
||||
})
|
||||
})
|
||||
148
apps/alta-clientes/src/lib/curp.ts
Normal file
148
apps/alta-clientes/src/lib/curp.ts
Normal 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
|
||||
}
|
||||
48
apps/alta-clientes/src/lib/expediente.test.ts
Normal file
48
apps/alta-clientes/src/lib/expediente.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
34
apps/alta-clientes/src/lib/expediente.ts
Normal file
34
apps/alta-clientes/src/lib/expediente.ts
Normal 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 }
|
||||
}
|
||||
156
apps/alta-clientes/src/lib/fixture.ts
Normal file
156
apps/alta-clientes/src/lib/fixture.ts
Normal 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',
|
||||
},
|
||||
}
|
||||
48
apps/alta-clientes/src/lib/formatters.ts
Normal file
48
apps/alta-clientes/src/lib/formatters.ts
Normal 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`
|
||||
}
|
||||
51
apps/alta-clientes/src/lib/ocrIne.test.ts
Normal file
51
apps/alta-clientes/src/lib/ocrIne.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
81
apps/alta-clientes/src/lib/ocrIne.ts
Normal file
81
apps/alta-clientes/src/lib/ocrIne.ts
Normal 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
|
||||
}
|
||||
39
apps/alta-clientes/src/lib/pdf/avisoPrivacidad.ts
Normal file
39
apps/alta-clientes/src/lib/pdf/avisoPrivacidad.ts
Normal 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)
|
||||
}
|
||||
54
apps/alta-clientes/src/lib/pdf/cartaAntilavado.ts
Normal file
54
apps/alta-clientes/src/lib/pdf/cartaAntilavado.ts
Normal 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)
|
||||
}
|
||||
116
apps/alta-clientes/src/lib/pdf/checklistExpediente.ts
Normal file
116
apps/alta-clientes/src/lib/pdf/checklistExpediente.ts
Normal 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)
|
||||
}
|
||||
79
apps/alta-clientes/src/lib/pdf/helpers.ts
Normal file
79
apps/alta-clientes/src/lib/pdf/helpers.ts
Normal 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
|
||||
}
|
||||
233
apps/alta-clientes/src/lib/pdf/identificacionCliente.ts
Normal file
233
apps/alta-clientes/src/lib/pdf/identificacionCliente.ts
Normal 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)
|
||||
}
|
||||
33
apps/alta-clientes/src/lib/pdf/pdf.test.ts
Normal file
33
apps/alta-clientes/src/lib/pdf/pdf.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
245
apps/alta-clientes/src/lib/pdf/perfilCliente.ts
Normal file
245
apps/alta-clientes/src/lib/pdf/perfilCliente.ts
Normal 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)
|
||||
}
|
||||
173
apps/alta-clientes/src/lib/schemas.test.ts
Normal file
173
apps/alta-clientes/src/lib/schemas.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
219
apps/alta-clientes/src/lib/schemas.ts
Normal file
219
apps/alta-clientes/src/lib/schemas.ts
Normal 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'),
|
||||
})
|
||||
14
apps/alta-clientes/src/lib/uuid.ts
Normal file
14
apps/alta-clientes/src/lib/uuid.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
20
apps/alta-clientes/src/main.tsx
Normal file
20
apps/alta-clientes/src/main.tsx
Normal 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>,
|
||||
)
|
||||
134
apps/alta-clientes/src/pages/AltaClientePage.tsx
Normal file
134
apps/alta-clientes/src/pages/AltaClientePage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
21
apps/alta-clientes/src/services/api.ts
Normal file
21
apps/alta-clientes/src/services/api.ts
Normal 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')
|
||||
}
|
||||
12
apps/alta-clientes/src/services/monday.ts
Normal file
12
apps/alta-clientes/src/services/monday.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
157
apps/alta-clientes/src/types/index.ts
Normal file
157
apps/alta-clientes/src/types/index.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user