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:
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
# Dependencias y builds
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
|
||||
# Entorno y secretos
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Material de referencia (contiene datos personales de formatos reales)
|
||||
reference/
|
||||
|
||||
# Sistema
|
||||
.DS_Store
|
||||
*.log
|
||||
9
apps/alta-clientes/.dockerignore
Normal file
9
apps/alta-clientes/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
*.log
|
||||
coverage
|
||||
.vscode
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
10
apps/alta-clientes/.gitignore
vendored
Normal file
10
apps/alta-clientes/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
*.tsbuildinfo
|
||||
coverage
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.DS_Store
|
||||
*.log
|
||||
14
apps/alta-clientes/Dockerfile
Normal file
14
apps/alta-clientes/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
# Etapa 1: build
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Etapa 2: servidor estático
|
||||
FROM nginx:alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
12
apps/alta-clientes/index.html
Normal file
12
apps/alta-clientes/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>FRASA Desarrollos — Alta de Clientes</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
18
apps/alta-clientes/nginx.conf
Normal file
18
apps/alta-clientes/nginx.conf
Normal file
@@ -0,0 +1,18 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA fallback: cualquier ruta no existente sirve index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Assets con hash: cache agresivo
|
||||
location /client/assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
4390
apps/alta-clientes/package-lock.json
generated
Normal file
4390
apps/alta-clientes/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
apps/alta-clientes/package.json
Normal file
37
apps/alta-clientes/package.json
Normal file
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "alta-clientes",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"jspdf": "^2.5.2",
|
||||
"jspdf-autotable": "^3.8.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-dropzone": "^14.3.5",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"react-signature-canvas": "^1.1.0-alpha.2",
|
||||
"tesseract.js": "^7.0.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "~5.6.3",
|
||||
"vite": "^6.0.7",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
BIN
apps/alta-clientes/public/tessdata/spa.traineddata
Normal file
BIN
apps/alta-clientes/public/tessdata/spa.traineddata
Normal file
Binary file not shown.
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
|
||||
}
|
||||
22
apps/alta-clientes/tsconfig.app.json
Normal file
22
apps/alta-clientes/tsconfig.app.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
apps/alta-clientes/tsconfig.json
Normal file
7
apps/alta-clientes/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
19
apps/alta-clientes/tsconfig.node.json
Normal file
19
apps/alta-clientes/tsconfig.node.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts", "vitest.config.ts"]
|
||||
}
|
||||
9
apps/alta-clientes/vite.config.ts
Normal file
9
apps/alta-clientes/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// La app se sirve detrás de NGINX bajo el prefijo /client
|
||||
export default defineConfig({
|
||||
base: '/client/',
|
||||
plugins: [react(), tailwindcss()],
|
||||
})
|
||||
8
apps/alta-clientes/vitest.config.ts
Normal file
8
apps/alta-clientes/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
// Config de tests separada para no mezclar los tipos de Vite y Vitest
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
},
|
||||
})
|
||||
9
apps/cobranza/.dockerignore
Normal file
9
apps/cobranza/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
*.log
|
||||
coverage
|
||||
.vscode
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
5
apps/cobranza/.gitignore
vendored
Normal file
5
apps/cobranza/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
*.log
|
||||
coverage
|
||||
*.tsbuildinfo
|
||||
14
apps/cobranza/Dockerfile
Normal file
14
apps/cobranza/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
||||
# Etapa 1: build
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
# Etapa 2: servidor estático
|
||||
FROM nginx:alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
12
apps/cobranza/index.html
Normal file
12
apps/cobranza/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>FRASA Desarrollos — Cobranza</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
18
apps/cobranza/nginx.conf
Normal file
18
apps/cobranza/nginx.conf
Normal file
@@ -0,0 +1,18 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# SPA fallback: cualquier ruta no existente sirve index.html
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Assets con hash: cache agresivo
|
||||
location /pay/assets/ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
4525
apps/cobranza/package-lock.json
generated
Normal file
4525
apps/cobranza/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
34
apps/cobranza/package.json
Normal file
34
apps/cobranza/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "cobranza",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"test": "vitest run",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "^5.3.0",
|
||||
"@hookform/resolvers": "^3.9.1",
|
||||
"jspdf": "^2.5.2",
|
||||
"jspdf-autotable": "^3.8.4",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.54.2",
|
||||
"react-router-dom": "^7.1.1",
|
||||
"recharts": "^2.15.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "~5.6.3",
|
||||
"vite": "^6.0.7",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
18
apps/cobranza/src/components/BadgeStatus.tsx
Normal file
18
apps/cobranza/src/components/BadgeStatus.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { StatusPago } from '../types'
|
||||
|
||||
const CLASES: Record<StatusPago, string> = {
|
||||
PENDIENTE: 'border-neutral-300 text-neutral-500',
|
||||
PAGADO: 'border-green-300 text-green-700',
|
||||
VENCIDO: 'border-red-300 text-red-700',
|
||||
PARCIAL: 'border-amber-300 text-amber-700',
|
||||
}
|
||||
|
||||
export default function BadgeStatus({ status }: { status: StatusPago }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-block border bg-white px-2 py-0.5 text-xs font-bold uppercase tracking-wider ${CLASES[status]}`}
|
||||
>
|
||||
{status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
11
apps/cobranza/src/components/KpiCard.tsx
Normal file
11
apps/cobranza/src/components/KpiCard.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export default function KpiCard({ titulo, valor, detalle }: { titulo: string; valor: ReactNode; detalle?: string }) {
|
||||
return (
|
||||
<div className="border border-neutral-200 bg-white p-6">
|
||||
<p className="text-xs font-bold uppercase tracking-widest text-neutral-500">{titulo}</p>
|
||||
<p className="mt-2 text-3xl font-light text-neutral-900">{valor}</p>
|
||||
{detalle && <p className="mt-2 text-xs text-neutral-500">{detalle}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
38
apps/cobranza/src/components/Layout.tsx
Normal file
38
apps/cobranza/src/components/Layout.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white text-neutral-900">
|
||||
<header className="border-b border-neutral-200">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-4">
|
||||
<Link to="/" className="text-sm tracking-widest">
|
||||
<span className="font-bold">FRASA</span>{' '}
|
||||
<span className="font-light text-neutral-500">COBRANZA</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-6 text-sm">
|
||||
<Link
|
||||
to="/"
|
||||
className="text-neutral-500 transition-colors duration-150 hover:text-neutral-900"
|
||||
>
|
||||
Cobranza
|
||||
</Link>
|
||||
<Link
|
||||
to="/hoy"
|
||||
className="border border-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-accent transition-colors duration-150 hover:bg-accent hover:text-white"
|
||||
>
|
||||
Cobranza del día
|
||||
</Link>
|
||||
<Link
|
||||
to="/nuevo"
|
||||
className="bg-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:opacity-90"
|
||||
>
|
||||
Nuevo plan
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-6xl px-4 py-16">{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
141
apps/cobranza/src/components/ModalAbono.tsx
Normal file
141
apps/cobranza/src/components/ModalAbono.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import type { Pago } from '../types'
|
||||
import { fechaISOHoy, formatoMoneda } from '../lib/formatters'
|
||||
import { round2 } from '../lib/cobranza'
|
||||
|
||||
function esquemaAbono(restante: number) {
|
||||
return z.object({
|
||||
monto: z
|
||||
.number({ invalid_type_error: 'Captura un monto válido' })
|
||||
.positive('El monto debe ser mayor a 0')
|
||||
.max(restante, `No puede exceder el restante (${formatoMoneda(restante)})`),
|
||||
fechaPago: z.string().min(1, 'La fecha es requerida'),
|
||||
referencia: z.string().trim().min(3, 'Referencia requerida (mín. 3 caracteres)'),
|
||||
})
|
||||
}
|
||||
|
||||
type AbonoForm = z.infer<ReturnType<typeof esquemaAbono>>
|
||||
|
||||
interface Props {
|
||||
pago: Pago
|
||||
onCancelar: () => void
|
||||
onGuardar: (datos: AbonoForm) => void
|
||||
}
|
||||
|
||||
const claseLabel = 'block text-xs font-bold uppercase tracking-widest text-neutral-500'
|
||||
|
||||
/** Modal para registrar un abono (total o parcial) a un pago del plan */
|
||||
export default function ModalAbono({ pago, onCancelar, onGuardar }: Props) {
|
||||
const restante = round2(pago.monto - pago.montoPagado)
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm<AbonoForm>({
|
||||
resolver: zodResolver(esquemaAbono(restante)),
|
||||
defaultValues: { monto: restante, fechaPago: fechaISOHoy(), referencia: '' },
|
||||
})
|
||||
|
||||
// Foco al primer input al abrir
|
||||
const refMonto = useRef<HTMLInputElement | null>(null)
|
||||
useEffect(() => {
|
||||
refMonto.current?.focus()
|
||||
}, [])
|
||||
|
||||
// Escape cierra el modal
|
||||
useEffect(() => {
|
||||
const alTeclar = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onCancelar()
|
||||
}
|
||||
window.addEventListener('keydown', alTeclar)
|
||||
return () => window.removeEventListener('keydown', alTeclar)
|
||||
}, [onCancelar])
|
||||
|
||||
const regMonto = register('monto', { valueAsNumber: true })
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="titulo-abono"
|
||||
className="w-full max-w-md border border-neutral-900 bg-white p-8"
|
||||
>
|
||||
<h2 id="titulo-abono" className="text-xl font-light text-neutral-900">
|
||||
Registrar abono — Pago #{pago.numPago}
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-neutral-500">
|
||||
Restante: <span className="font-bold text-neutral-900">{formatoMoneda(restante)}</span>
|
||||
</p>
|
||||
<form
|
||||
className="mt-8 space-y-6"
|
||||
onSubmit={(e) => {
|
||||
void handleSubmit(onGuardar)(e)
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label htmlFor="abono-monto" className={claseLabel}>
|
||||
Monto (MXN)
|
||||
</label>
|
||||
<input
|
||||
id="abono-monto"
|
||||
type="number"
|
||||
step="0.01"
|
||||
className={`mt-1 w-full border bg-white px-3 py-2.5 text-sm focus:border-neutral-900 focus:outline-none ${errors.monto ? 'border-red-400' : 'border-neutral-300'}`}
|
||||
{...regMonto}
|
||||
ref={(el) => {
|
||||
regMonto.ref(el)
|
||||
refMonto.current = el
|
||||
}}
|
||||
/>
|
||||
{errors.monto && <p className="mt-1 text-xs text-red-700">{errors.monto.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="abono-fecha" className={claseLabel}>
|
||||
Fecha de pago
|
||||
</label>
|
||||
<input
|
||||
id="abono-fecha"
|
||||
type="date"
|
||||
className={`mt-1 w-full border bg-white px-3 py-2.5 text-sm focus:border-neutral-900 focus:outline-none ${errors.fechaPago ? 'border-red-400' : 'border-neutral-300'}`}
|
||||
{...register('fechaPago')}
|
||||
/>
|
||||
{errors.fechaPago && <p className="mt-1 text-xs text-red-700">{errors.fechaPago.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="abono-referencia" className={claseLabel}>
|
||||
Referencia bancaria / folio
|
||||
</label>
|
||||
<input
|
||||
id="abono-referencia"
|
||||
type="text"
|
||||
className={`mt-1 w-full border bg-white px-3 py-2.5 text-sm focus:border-neutral-900 focus:outline-none ${errors.referencia ? 'border-red-400' : 'border-neutral-300'}`}
|
||||
placeholder="SPEI-000123"
|
||||
{...register('referencia')}
|
||||
/>
|
||||
{errors.referencia && <p className="mt-1 text-xs text-red-700">{errors.referencia.message}</p>}
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancelar}
|
||||
aria-label="Cancelar y cerrar el registro de abono"
|
||||
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"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
Guardar abono
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
apps/cobranza/src/components/Semaforo.tsx
Normal file
45
apps/cobranza/src/components/Semaforo.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { PlanPago } from '../types'
|
||||
import { diasParaProximoVencimiento, statusPago, statusPlan } from '../lib/cobranza'
|
||||
|
||||
export type Semaforo = 'VERDE' | 'AMBAR' | 'ROJO' | 'GRIS'
|
||||
|
||||
const ETIQUETA: Record<Semaforo, string> = {
|
||||
VERDE: 'Al corriente',
|
||||
AMBAR: 'Por vencer / parcial',
|
||||
ROJO: 'Vencido',
|
||||
GRIS: 'Cerrado',
|
||||
}
|
||||
|
||||
const CLASE_DOT: Record<Semaforo, string> = {
|
||||
VERDE: 'border-green-300 bg-white text-green-700',
|
||||
AMBAR: 'border-amber-300 bg-white text-amber-700',
|
||||
ROJO: 'border-red-300 bg-white text-red-700',
|
||||
GRIS: 'border-neutral-300 bg-white text-neutral-500',
|
||||
}
|
||||
|
||||
/**
|
||||
* Semáforo del plan:
|
||||
* - gris: LIQUIDADO o CANCELADO
|
||||
* - rojo: tiene pagos VENCIDOS
|
||||
* - ámbar: tiene un pago PARCIAL o el próximo vencimiento es en 7 días o menos
|
||||
* - verde: al corriente
|
||||
*/
|
||||
export function semaforoPlan(plan: PlanPago, hoy: Date = new Date()): Semaforo {
|
||||
if (statusPlan(plan) !== 'ACTIVO') return 'GRIS'
|
||||
const estados = plan.pagos.map((p) => statusPago(p, hoy))
|
||||
if (estados.includes('VENCIDO')) return 'ROJO'
|
||||
if (estados.includes('PARCIAL')) return 'AMBAR'
|
||||
const dias = diasParaProximoVencimiento(plan, hoy)
|
||||
if (dias !== null && dias <= 7) return 'AMBAR'
|
||||
return 'VERDE'
|
||||
}
|
||||
|
||||
export default function SemaforoDot({ valor }: { valor: Semaforo }) {
|
||||
return (
|
||||
<span
|
||||
className={`inline-block border px-2 py-0.5 text-xs font-bold uppercase tracking-wider ${CLASE_DOT[valor]}`}
|
||||
>
|
||||
{ETIQUETA[valor]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
124
apps/cobranza/src/data/seed.ts
Normal file
124
apps/cobranza/src/data/seed.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import type { PlanPago } from '../types'
|
||||
import { generarCalendario, registrarAbono, sumarMeses } from '../lib/cobranza'
|
||||
import { uuid } from '../lib/uuid'
|
||||
|
||||
function aISO(fecha: 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}`
|
||||
}
|
||||
|
||||
interface DatosPlan {
|
||||
clienteNombre: string
|
||||
clienteTelefono?: string
|
||||
unidadCodigo: string
|
||||
proyecto: string
|
||||
ventaRef: string
|
||||
fechaInicio: string
|
||||
engancheTotal: number
|
||||
numPagos: number
|
||||
tasaMoraMensual?: number
|
||||
}
|
||||
|
||||
function crearPlanBase(datos: DatosPlan): PlanPago {
|
||||
const { mensualidad, pagos } = generarCalendario(datos.fechaInicio, datos.engancheTotal, datos.numPagos)
|
||||
return {
|
||||
id: uuid(),
|
||||
ventaRef: datos.ventaRef,
|
||||
clienteNombre: datos.clienteNombre,
|
||||
clienteTelefono: datos.clienteTelefono,
|
||||
unidadCodigo: datos.unidadCodigo,
|
||||
proyecto: datos.proyecto,
|
||||
fechaInicio: datos.fechaInicio,
|
||||
engancheTotal: datos.engancheTotal,
|
||||
numPagos: datos.numPagos,
|
||||
mensualidad,
|
||||
tasaMoraMensual: datos.tasaMoraMensual,
|
||||
status: 'ACTIVO',
|
||||
pagos,
|
||||
notas: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
/** Abona por número de pago (1..N); `monto` por defecto cubre el pago completo */
|
||||
function abonar(plan: PlanPago, numPago: number, fechaPago: string, referencia: string, monto?: number): PlanPago {
|
||||
const pago = plan.pagos[numPago - 1]
|
||||
return registrarAbono(plan, {
|
||||
pagoId: pago.id,
|
||||
fechaPago,
|
||||
monto: monto ?? pago.monto - pago.montoPagado,
|
||||
referencia,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 4 planes de ejemplo con fechas relativas a `hoy`, para que el demo siempre
|
||||
* muestre todos los estados: al corriente, con vencidos, con parcial y liquidado.
|
||||
*/
|
||||
export function generarSeed(hoy: Date = new Date()): PlanPago[] {
|
||||
// 1. Al corriente: 6 pagos desde hace 3 meses, los primeros 3 pagados
|
||||
let alCorriente = crearPlanBase({
|
||||
clienteNombre: 'María Fernanda López Ríos',
|
||||
clienteTelefono: '55 1234 8890',
|
||||
unidadCodigo: 'MB-204',
|
||||
proyecto: 'RESIDENCIAL MONTEBELLO',
|
||||
ventaRef: 'VTA-2026-014',
|
||||
fechaInicio: aISO(sumarMeses(hoy, -3)),
|
||||
engancheTotal: 180000,
|
||||
numPagos: 6,
|
||||
})
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
alCorriente = abonar(alCorriente, i, alCorriente.pagos[i - 1].fechaVencimiento, `SPEI-MB204-0${i}`)
|
||||
}
|
||||
|
||||
// 2. Con 2 pagos vencidos: 6 pagos desde hace 5 meses, solo 3 pagados
|
||||
let conVencidos = crearPlanBase({
|
||||
clienteNombre: 'Jorge Alberto Camacho Vega',
|
||||
clienteTelefono: '55 8842 1177',
|
||||
unidadCodigo: 'U-101',
|
||||
proyecto: 'SENDA',
|
||||
ventaRef: 'VTA-2025-089',
|
||||
fechaInicio: aISO(sumarMeses(hoy, -5)),
|
||||
engancheTotal: 150000,
|
||||
numPagos: 6,
|
||||
tasaMoraMensual: 2,
|
||||
})
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
conVencidos = abonar(conVencidos, i, conVencidos.pagos[i - 1].fechaVencimiento, `SPEI-U101-0${i}`)
|
||||
}
|
||||
|
||||
// 3. Con un pago PARCIAL: 4 pagos desde hace 2 meses; pago 1 completo, pago 2 con abono parcial ya vencido
|
||||
let conParcial = crearPlanBase({
|
||||
clienteNombre: 'Ana Sofía Guillén Torres',
|
||||
clienteTelefono: '55 6021 3345',
|
||||
unidadCodigo: 'DG-302',
|
||||
proyecto: 'DIAGONAL',
|
||||
ventaRef: 'VTA-2026-021',
|
||||
fechaInicio: aISO(sumarMeses(hoy, -2)),
|
||||
engancheTotal: 120000,
|
||||
numPagos: 4,
|
||||
tasaMoraMensual: 1.5,
|
||||
})
|
||||
conParcial = abonar(conParcial, 1, conParcial.pagos[0].fechaVencimiento, 'SPEI-DG302-01')
|
||||
conParcial = abonar(conParcial, 2, aISO(sumarMeses(hoy, -1)), 'EFECTIVO-DG302-02', 10000)
|
||||
|
||||
// 4. Liquidado: 6 pagos desde hace 6 meses, todos pagados
|
||||
let liquidado = crearPlanBase({
|
||||
clienteNombre: 'Roberto Mendoza Salas',
|
||||
clienteTelefono: '55 9987 4456',
|
||||
unidadCodigo: 'MB-118',
|
||||
proyecto: 'RESIDENCIAL MONTEBELLO',
|
||||
ventaRef: 'VTA-2025-041',
|
||||
fechaInicio: aISO(sumarMeses(hoy, -6)),
|
||||
engancheTotal: 90000,
|
||||
numPagos: 6,
|
||||
})
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
liquidado = abonar(liquidado, i, liquidado.pagos[i - 1].fechaVencimiento, `SPEI-MB118-0${i}`)
|
||||
}
|
||||
liquidado = { ...liquidado, status: 'LIQUIDADO' }
|
||||
|
||||
return [alCorriente, conVencidos, conParcial, liquidado]
|
||||
}
|
||||
23
apps/cobranza/src/index.css
Normal file
23
apps/cobranza/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;
|
||||
}
|
||||
}
|
||||
326
apps/cobranza/src/lib/cobranza.test.ts
Normal file
326
apps/cobranza/src/lib/cobranza.test.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Pago, PlanPago } from '../types'
|
||||
import {
|
||||
agregarNotaAPlan,
|
||||
alCorriente,
|
||||
avancePlan,
|
||||
cobranzaDelDia,
|
||||
diasAtraso,
|
||||
generarCalendario,
|
||||
moraDePago,
|
||||
pagadoTotal,
|
||||
proximoVencimiento,
|
||||
registrarAbono,
|
||||
round2,
|
||||
saldoPlan,
|
||||
statusPago,
|
||||
statusPlan,
|
||||
sumarMeses,
|
||||
totalMora,
|
||||
} from './cobranza'
|
||||
|
||||
const pagoBase = (override: Partial<Pago> = {}): Pago => ({
|
||||
id: 'p1',
|
||||
numPago: 1,
|
||||
fechaVencimiento: '2026-02-15',
|
||||
monto: 10000,
|
||||
montoPagado: 0,
|
||||
aplicaciones: [],
|
||||
...override,
|
||||
})
|
||||
|
||||
describe('generarCalendario', () => {
|
||||
it('genera 12 pagos cuya suma es exactamente el enganche', () => {
|
||||
const { mensualidad, pagos } = generarCalendario('2026-01-15', 180000, 12)
|
||||
expect(pagos).toHaveLength(12)
|
||||
expect(mensualidad).toBe(15000)
|
||||
const suma = pagos.reduce((acc, p) => acc + p.monto, 0)
|
||||
expect(round2(suma)).toBe(180000)
|
||||
expect(pagos[0].numPago).toBe(1)
|
||||
expect(pagos[11].numPago).toBe(12)
|
||||
expect(pagos[0].fechaVencimiento).toBe('2026-01-15')
|
||||
expect(pagos[11].fechaVencimiento).toBe('2026-12-15')
|
||||
})
|
||||
|
||||
it('el último pago absorbe el residuo del redondeo (100000 / 3)', () => {
|
||||
const { mensualidad, pagos } = generarCalendario('2026-03-01', 100000, 3)
|
||||
expect(mensualidad).toBe(33333.33)
|
||||
expect(pagos.map((p) => p.monto)).toEqual([33333.33, 33333.33, 33333.34])
|
||||
expect(round2(pagos.reduce((acc, p) => acc + p.monto, 0))).toBe(100000)
|
||||
})
|
||||
|
||||
it('ajusta el día cuando el mes no lo tiene (31 de enero → 28 de febrero)', () => {
|
||||
const { pagos } = generarCalendario('2025-01-31', 30000, 3)
|
||||
expect(pagos[0].fechaVencimiento).toBe('2025-01-31')
|
||||
expect(pagos[1].fechaVencimiento).toBe('2025-02-28') // 2025 no es bisiesto
|
||||
expect(pagos[2].fechaVencimiento).toBe('2025-03-31')
|
||||
})
|
||||
|
||||
it('respeta año bisiesto (31 de enero 2024 → 29 de febrero)', () => {
|
||||
expect(sumarMeses(new Date(2024, 0, 31), 1)).toEqual(new Date(2024, 1, 29))
|
||||
})
|
||||
|
||||
it('rechaza parámetros inválidos', () => {
|
||||
expect(() => generarCalendario('2026-01-01', 0, 6)).toThrow()
|
||||
expect(() => generarCalendario('2026-01-01', 1000, 0)).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('statusPago', () => {
|
||||
const hoy = new Date('2026-02-16T00:00:00')
|
||||
|
||||
it('PAGADO cuando montoPagado cubre el monto', () => {
|
||||
expect(statusPago(pagoBase({ montoPagado: 10000 }), hoy)).toBe('PAGADO')
|
||||
})
|
||||
|
||||
it('PARCIAL con abono menor al monto, aunque ya venció', () => {
|
||||
expect(statusPago(pagoBase({ montoPagado: 4000 }), hoy)).toBe('PARCIAL')
|
||||
})
|
||||
|
||||
it('VENCIDO sin abono y hoy posterior al vencimiento', () => {
|
||||
expect(statusPago(pagoBase(), hoy)).toBe('VENCIDO')
|
||||
})
|
||||
|
||||
it('PENDIENTE sin abono y hoy en o antes del vencimiento', () => {
|
||||
expect(statusPago(pagoBase(), new Date('2026-02-15T00:00:00'))).toBe('PENDIENTE')
|
||||
expect(statusPago(pagoBase(), new Date('2026-02-01T00:00:00'))).toBe('PENDIENTE')
|
||||
})
|
||||
})
|
||||
|
||||
function planDePrueba(): PlanPago {
|
||||
const { mensualidad, pagos } = generarCalendario('2026-01-10', 30000, 3)
|
||||
return {
|
||||
id: 'plan-1',
|
||||
ventaRef: 'VTA-001',
|
||||
clienteNombre: 'Cliente Prueba',
|
||||
unidadCodigo: 'U-1',
|
||||
proyecto: 'SENDA',
|
||||
fechaInicio: '2026-01-10',
|
||||
engancheTotal: 30000,
|
||||
numPagos: 3,
|
||||
mensualidad,
|
||||
status: 'ACTIVO',
|
||||
pagos,
|
||||
notas: [],
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
}
|
||||
|
||||
describe('registrarAbono', () => {
|
||||
it('aplica un abono parcial y lo agrega al historial', () => {
|
||||
const plan = planDePrueba()
|
||||
const actualizado = registrarAbono(plan, {
|
||||
pagoId: plan.pagos[0].id,
|
||||
fechaPago: '2026-01-10',
|
||||
monto: 4000,
|
||||
referencia: 'SPEI-123',
|
||||
})
|
||||
expect(actualizado.pagos[0].montoPagado).toBe(4000)
|
||||
expect(actualizado.pagos[0].aplicaciones).toHaveLength(1)
|
||||
expect(actualizado.pagos[0].aplicaciones[0].referencia).toBe('SPEI-123')
|
||||
// no muta el plan original
|
||||
expect(plan.pagos[0].montoPagado).toBe(0)
|
||||
})
|
||||
|
||||
it('permite completar un pago con varios abonos', () => {
|
||||
let plan = planDePrueba()
|
||||
plan = registrarAbono(plan, { pagoId: plan.pagos[0].id, fechaPago: '2026-01-10', monto: 4000, referencia: 'A1' })
|
||||
plan = registrarAbono(plan, { pagoId: plan.pagos[0].id, fechaPago: '2026-01-11', monto: 6000, referencia: 'A2' })
|
||||
expect(plan.pagos[0].montoPagado).toBe(10000)
|
||||
expect(plan.pagos[0].aplicaciones).toHaveLength(2)
|
||||
expect(statusPago(plan.pagos[0], new Date('2026-01-12T00:00:00'))).toBe('PAGADO')
|
||||
})
|
||||
|
||||
it('rechaza abonos que exceden el restante', () => {
|
||||
const plan = planDePrueba()
|
||||
expect(() =>
|
||||
registrarAbono(plan, { pagoId: plan.pagos[0].id, fechaPago: '2026-01-10', monto: 10000.01, referencia: 'X1' }),
|
||||
).toThrow(/excede el restante/)
|
||||
})
|
||||
|
||||
it('rechaza abonos menores o iguales a 0', () => {
|
||||
const plan = planDePrueba()
|
||||
expect(() =>
|
||||
registrarAbono(plan, { pagoId: plan.pagos[0].id, fechaPago: '2026-01-10', monto: 0, referencia: 'X1' }),
|
||||
).toThrow(/mayor a 0/)
|
||||
expect(() =>
|
||||
registrarAbono(plan, { pagoId: plan.pagos[0].id, fechaPago: '2026-01-10', monto: -500, referencia: 'X1' }),
|
||||
).toThrow(/mayor a 0/)
|
||||
})
|
||||
|
||||
it('rechaza un pagoId inexistente', () => {
|
||||
const plan = planDePrueba()
|
||||
expect(() =>
|
||||
registrarAbono(plan, { pagoId: 'no-existe', fechaPago: '2026-01-10', monto: 100, referencia: 'X1' }),
|
||||
).toThrow(/no encontrado/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('saldo, avance y status del plan', () => {
|
||||
it('saldoPlan y pagadoTotal reflejan los abonos', () => {
|
||||
let plan = planDePrueba()
|
||||
expect(saldoPlan(plan)).toBe(30000)
|
||||
expect(pagadoTotal(plan)).toBe(0)
|
||||
plan = registrarAbono(plan, { pagoId: plan.pagos[0].id, fechaPago: '2026-01-10', monto: 10000, referencia: 'A1' })
|
||||
expect(saldoPlan(plan)).toBe(20000)
|
||||
expect(pagadoTotal(plan)).toBe(10000)
|
||||
expect(avancePlan(plan)).toBeCloseTo(1 / 3, 5)
|
||||
})
|
||||
|
||||
it('el plan queda LIQUIDADO al cubrir todo el enganche', () => {
|
||||
let plan = planDePrueba()
|
||||
for (const pago of plan.pagos) {
|
||||
plan = registrarAbono(plan, {
|
||||
pagoId: pago.id,
|
||||
fechaPago: '2026-03-10',
|
||||
monto: pago.monto - pago.montoPagado,
|
||||
referencia: `LIQ-${pago.numPago}`,
|
||||
})
|
||||
}
|
||||
expect(saldoPlan(plan)).toBe(0)
|
||||
expect(statusPlan(plan)).toBe('LIQUIDADO')
|
||||
})
|
||||
|
||||
it('CANCELADO prevalece aunque tenga saldo', () => {
|
||||
const plan = { ...planDePrueba(), status: 'CANCELADO' as const }
|
||||
expect(statusPlan(plan)).toBe('CANCELADO')
|
||||
})
|
||||
|
||||
it('alCorriente es falso cuando hay pagos vencidos', () => {
|
||||
const hoy = new Date('2026-02-15T00:00:00') // pago 1 (10-ene) ya venció
|
||||
expect(alCorriente(planDePrueba(), hoy)).toBe(false)
|
||||
expect(alCorriente(planDePrueba(), new Date('2026-01-05T00:00:00'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('proximoVencimiento y diasAtraso', () => {
|
||||
it('devuelve el primer pago no cubierto con fecha >= hoy', () => {
|
||||
const plan = planDePrueba() // vencimientos 10-ene, 10-feb, 10-mar
|
||||
const prox = proximoVencimiento(plan, new Date('2026-01-20T00:00:00'))
|
||||
expect(prox?.numPago).toBe(2)
|
||||
})
|
||||
|
||||
it('si no hay futuros, devuelve el vencido más antiguo', () => {
|
||||
const plan = planDePrueba()
|
||||
const prox = proximoVencimiento(plan, new Date('2026-04-01T00:00:00'))
|
||||
expect(prox?.numPago).toBe(1)
|
||||
})
|
||||
|
||||
it('devuelve null cuando todo está cubierto', () => {
|
||||
let plan = planDePrueba()
|
||||
for (const pago of plan.pagos) {
|
||||
plan = registrarAbono(plan, {
|
||||
pagoId: pago.id,
|
||||
fechaPago: '2026-01-10',
|
||||
monto: pago.monto,
|
||||
referencia: 'X',
|
||||
})
|
||||
}
|
||||
expect(proximoVencimiento(plan, new Date('2026-01-15T00:00:00'))).toBeNull()
|
||||
})
|
||||
|
||||
it('diasAtraso cuenta desde el vencido más antiguo', () => {
|
||||
const plan = planDePrueba() // pago 1 vence 10-ene
|
||||
expect(diasAtraso(plan, new Date('2026-01-25T00:00:00'))).toBe(15)
|
||||
expect(diasAtraso(plan, new Date('2026-01-10T00:00:00'))).toBe(0)
|
||||
})
|
||||
|
||||
it('un pago PARCIAL vencido no cuenta como atraso', () => {
|
||||
let plan = planDePrueba()
|
||||
plan = registrarAbono(plan, { pagoId: plan.pagos[0].id, fechaPago: '2026-01-10', monto: 100, referencia: 'A' })
|
||||
// pago 1 es PARCIAL (vencido pero con abono); pago 2 vence 10-feb
|
||||
expect(diasAtraso(plan, new Date('2026-02-01T00:00:00'))).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('moraDePago y totalMora', () => {
|
||||
// pago de 10000 que venció el 15-feb; hoy = 17-mar → 30 días vencidos
|
||||
const hoy = new Date('2026-03-17T00:00:00')
|
||||
|
||||
it('es 0 cuando la tasa es 0', () => {
|
||||
expect(moraDePago(pagoBase(), 0, hoy)).toBe(0)
|
||||
})
|
||||
|
||||
it('es 0 cuando el pago no está vencido', () => {
|
||||
expect(moraDePago(pagoBase(), 2, new Date('2026-02-15T00:00:00'))).toBe(0)
|
||||
expect(moraDePago(pagoBase(), 2, new Date('2026-02-10T00:00:00'))).toBe(0)
|
||||
})
|
||||
|
||||
it('es 0 cuando el pago ya está cubierto', () => {
|
||||
expect(moraDePago(pagoBase({ montoPagado: 10000 }), 2, hoy)).toBe(0)
|
||||
})
|
||||
|
||||
it('cálculo exacto: restante × (tasa/100) / 30 × días vencidos', () => {
|
||||
// 10000 × 0.02 / 30 × 30 = 200
|
||||
expect(moraDePago(pagoBase(), 2, hoy)).toBe(200)
|
||||
// 10000 × 0.03 / 30 × 10 = 100 (10 días de atraso)
|
||||
expect(moraDePago(pagoBase(), 3, new Date('2026-02-25T00:00:00'))).toBe(100)
|
||||
})
|
||||
|
||||
it('un pago PARCIAL vencido genera mora sobre su restante', () => {
|
||||
// restante 6000 × 0.02 / 30 × 30 = 120
|
||||
expect(moraDePago(pagoBase({ montoPagado: 4000 }), 2, hoy)).toBe(120)
|
||||
})
|
||||
|
||||
it('totalMora suma la mora de todos los pagos y usa tasa 0 por defecto', () => {
|
||||
const plan = planDePrueba() // vence 10-ene, 10-feb, 10-mar; hoy 20-mar
|
||||
const hoyPlan = new Date('2026-03-20T00:00:00')
|
||||
expect(totalMora(plan, hoyPlan)).toBe(0) // sin tasa configurada
|
||||
const conTasa: PlanPago = { ...plan, tasaMoraMensual: 3 }
|
||||
// pago 1: 10000 × 0.001 × 69 = 690; pago 2: 10000 × 0.001 × 38 = 380; pago 3: 10000 × 0.001 × 10 = 100
|
||||
expect(totalMora(conTasa, hoyPlan)).toBe(1170)
|
||||
})
|
||||
})
|
||||
|
||||
describe('cobranzaDelDia', () => {
|
||||
it('incluye vencidos y los que vencen en los próximos 7 días, ordenados por urgencia', () => {
|
||||
// plan: vencimientos 10-ene, 10-feb, 10-mar; hoy 05-mar
|
||||
const hoy = new Date('2026-03-05T00:00:00')
|
||||
const plan = planDePrueba()
|
||||
const items = cobranzaDelDia([plan], hoy)
|
||||
// pago 1 (54 días atraso), pago 2 (23 días), pago 3 (vence en 5 días)
|
||||
expect(items.map((i) => i.pago.numPago)).toEqual([1, 2, 3])
|
||||
expect(items.map((i) => i.atraso)).toEqual([54, 23, 0])
|
||||
})
|
||||
|
||||
it('excluye pagos cubiertos, planes no activos y vencimientos lejanos', () => {
|
||||
const hoy = new Date('2026-01-05T00:00:00')
|
||||
let plan = planDePrueba()
|
||||
// cubre el pago 1 (vence 10-ene, dentro de la ventana)
|
||||
plan = registrarAbono(plan, { pagoId: plan.pagos[0].id, fechaPago: '2026-01-05', monto: 10000, referencia: 'A' })
|
||||
const cancelado: PlanPago = { ...planDePrueba(), status: 'CANCELADO' }
|
||||
const items = cobranzaDelDia([plan, cancelado], hoy)
|
||||
// pago 2 (10-feb) y pago 3 (10-mar) están a más de 7 días → lista vacía
|
||||
expect(items).toHaveLength(0)
|
||||
// con la ventana amplia solo aparecen los del plan activo
|
||||
const items40 = cobranzaDelDia([plan, cancelado], hoy, 40)
|
||||
expect(items40.map((i) => i.pago.numPago)).toEqual([2])
|
||||
})
|
||||
|
||||
it('un PARCIAL vencido aparece con sus días de atraso', () => {
|
||||
const hoy = new Date('2026-02-01T00:00:00')
|
||||
let plan = planDePrueba()
|
||||
plan = registrarAbono(plan, { pagoId: plan.pagos[0].id, fechaPago: '2026-01-10', monto: 4000, referencia: 'A' })
|
||||
const items = cobranzaDelDia([plan], hoy)
|
||||
expect(items[0].pago.numPago).toBe(1)
|
||||
expect(items[0].atraso).toBe(22)
|
||||
})
|
||||
})
|
||||
|
||||
describe('agregarNotaAPlan', () => {
|
||||
it('agrega la nota con texto limpio sin mutar el plan', () => {
|
||||
const plan = planDePrueba()
|
||||
const actualizado = agregarNotaAPlan(plan, ' Cliente promete pago el viernes ')
|
||||
expect(actualizado.notas).toHaveLength(1)
|
||||
expect(actualizado.notas[0].texto).toBe('Cliente promete pago el viernes')
|
||||
expect(actualizado.notas[0].id).toBeTruthy()
|
||||
expect(actualizado.notas[0].fecha).toBeTruthy()
|
||||
expect(plan.notas).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rechaza notas de menos de 3 caracteres', () => {
|
||||
const plan = planDePrueba()
|
||||
expect(() => agregarNotaAPlan(plan, ' ok ')).toThrow(/al menos 3/)
|
||||
expect(() => agregarNotaAPlan(plan, '')).toThrow(/al menos 3/)
|
||||
})
|
||||
})
|
||||
267
apps/cobranza/src/lib/cobranza.ts
Normal file
267
apps/cobranza/src/lib/cobranza.ts
Normal file
@@ -0,0 +1,267 @@
|
||||
import type { AplicacionPago, NotaPlan, Pago, PlanPago, StatusPago, StatusPlan } from '../types'
|
||||
import { uuid } from './uuid'
|
||||
|
||||
/** Redondeo monetario a 2 decimales */
|
||||
export const round2 = (x: number): number => Math.round(x * 100) / 100
|
||||
|
||||
/** Tolerancia para comparaciones de dinero (centavos) */
|
||||
export const EPS = 0.01
|
||||
|
||||
const gte = (a: number, b: number): boolean => a >= b - EPS
|
||||
|
||||
/** Parsea 'YYYY-MM-DD' como fecha LOCAL (nunca `new Date(iso)` a secas, que interpreta UTC) */
|
||||
export function parseFechaLocal(iso: string): Date {
|
||||
return new Date(`${iso}T00:00:00`)
|
||||
}
|
||||
|
||||
function aISO(fecha: 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}`
|
||||
}
|
||||
|
||||
/** Último día del mes (mes 0-based) */
|
||||
function ultimoDiaDelMes(anio: number, mes: number): number {
|
||||
return new Date(anio, mes + 1, 0).getDate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Suma `meses` conservando el día del mes; si el mes destino no tiene ese día,
|
||||
* usa su último día (p. ej. 31 de enero + 1 mes → 28/29 de febrero).
|
||||
*/
|
||||
export function sumarMeses(fecha: Date, meses: number): Date {
|
||||
const totalMeses = fecha.getMonth() + meses
|
||||
const anio = fecha.getFullYear() + Math.floor(totalMeses / 12)
|
||||
const mes = ((totalMeses % 12) + 12) % 12
|
||||
const dia = Math.min(fecha.getDate(), ultimoDiaDelMes(anio, mes))
|
||||
return new Date(anio, mes, dia)
|
||||
}
|
||||
|
||||
/** Compara solo la parte de fecha (ignora horas) */
|
||||
function compararFechas(a: Date, b: Date): number {
|
||||
const da = new Date(a.getFullYear(), a.getMonth(), a.getDate()).getTime()
|
||||
const db = new Date(b.getFullYear(), b.getMonth(), b.getDate()).getTime()
|
||||
return da - db
|
||||
}
|
||||
|
||||
export interface CalendarioGenerado {
|
||||
mensualidad: number
|
||||
pagos: Pago[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Genera N pagos con vencimiento mensual a partir de `fechaInicio`.
|
||||
* mensualidad = round2(engancheTotal / numPagos); el ÚLTIMO pago absorbe
|
||||
* el residuo del redondeo para que la suma sea exactamente `engancheTotal`.
|
||||
*/
|
||||
export function generarCalendario(
|
||||
fechaInicio: string,
|
||||
engancheTotal: number,
|
||||
numPagos: number,
|
||||
): CalendarioGenerado {
|
||||
if (numPagos < 1) throw new Error('numPagos debe ser al menos 1')
|
||||
if (engancheTotal <= 0) throw new Error('engancheTotal debe ser mayor a 0')
|
||||
const inicio = parseFechaLocal(fechaInicio)
|
||||
const mensualidad = round2(engancheTotal / numPagos)
|
||||
const pagos: Pago[] = []
|
||||
for (let i = 0; i < numPagos; i++) {
|
||||
const monto =
|
||||
i < numPagos - 1 ? mensualidad : round2(engancheTotal - mensualidad * (numPagos - 1))
|
||||
pagos.push({
|
||||
id: uuid(),
|
||||
numPago: i + 1,
|
||||
fechaVencimiento: aISO(sumarMeses(inicio, i)),
|
||||
monto,
|
||||
montoPagado: 0,
|
||||
aplicaciones: [],
|
||||
})
|
||||
}
|
||||
return { mensualidad, pagos }
|
||||
}
|
||||
|
||||
/**
|
||||
* Estado de un pago a la fecha `hoy`.
|
||||
* PAGADO si cubrió el monto; PARCIAL si tiene abonos sin cubrir; sin abonos:
|
||||
* VENCIDO si hoy > fechaVencimiento, si no PENDIENTE.
|
||||
* Decisión de negocio: un pago PARCIAL cuya fecha ya venció sigue siendo
|
||||
* PARCIAL (tiene abono aplicado); el atraso se señala vía `diasAtraso`.
|
||||
*/
|
||||
export function statusPago(pago: Pago, hoy: Date = new Date()): StatusPago {
|
||||
if (gte(pago.montoPagado, pago.monto)) return 'PAGADO'
|
||||
if (pago.montoPagado > EPS) return 'PARCIAL'
|
||||
return compararFechas(hoy, parseFechaLocal(pago.fechaVencimiento)) > 0 ? 'VENCIDO' : 'PENDIENTE'
|
||||
}
|
||||
|
||||
/** Saldo pendiente del plan: suma(monto - montoPagado) */
|
||||
export function saldoPlan(plan: PlanPago): number {
|
||||
return round2(plan.pagos.reduce((acc, p) => acc + (p.monto - p.montoPagado), 0))
|
||||
}
|
||||
|
||||
/** Total cobrado (suma de lo aplicado a todos los pagos) */
|
||||
export function pagadoTotal(plan: PlanPago): number {
|
||||
return round2(plan.pagos.reduce((acc, p) => acc + p.montoPagado, 0))
|
||||
}
|
||||
|
||||
/** Fracción de avance 0..1 (pagado / engancheTotal) */
|
||||
export function avancePlan(plan: PlanPago): number {
|
||||
return plan.engancheTotal > 0 ? pagadoTotal(plan) / plan.engancheTotal : 0
|
||||
}
|
||||
|
||||
const restante = (p: Pago): number => round2(p.monto - p.montoPagado)
|
||||
const estaCubierto = (p: Pago): boolean => gte(p.montoPagado, p.monto)
|
||||
|
||||
/**
|
||||
* Primer pago no cubierto con fecha >= hoy; si no hay, el vencido más antiguo.
|
||||
* Devuelve null si el plan está cubierto por completo.
|
||||
*/
|
||||
export function proximoVencimiento(plan: PlanPago, hoy: Date = new Date()): Pago | null {
|
||||
const noCubiertos = plan.pagos.filter((p) => !estaCubierto(p))
|
||||
const futuro = noCubiertos.find((p) => compararFechas(parseFechaLocal(p.fechaVencimiento), hoy) >= 0)
|
||||
if (futuro) return futuro
|
||||
const vencidos = noCubiertos.sort(
|
||||
(a, b) => parseFechaLocal(a.fechaVencimiento).getTime() - parseFechaLocal(b.fechaVencimiento).getTime(),
|
||||
)
|
||||
return vencidos[0] ?? null
|
||||
}
|
||||
|
||||
/** Días naturales hasta el próximo vencimiento; null si el plan está cubierto */
|
||||
export function diasParaProximoVencimiento(plan: PlanPago, hoy: Date = new Date()): number | null {
|
||||
const prox = proximoVencimiento(plan, hoy)
|
||||
if (!prox) return null
|
||||
return Math.round(compararFechas(parseFechaLocal(prox.fechaVencimiento), hoy) / 86400000)
|
||||
}
|
||||
|
||||
/** Días de atraso del pago vencido más antiguo (0 si no hay vencidos sin abono) */
|
||||
export function diasAtraso(plan: PlanPago, hoy: Date = new Date()): number {
|
||||
const vencidos = plan.pagos
|
||||
.filter((p) => statusPago(p, hoy) === 'VENCIDO')
|
||||
.sort(
|
||||
(a, b) =>
|
||||
parseFechaLocal(a.fechaVencimiento).getTime() - parseFechaLocal(b.fechaVencimiento).getTime(),
|
||||
)
|
||||
const primero = vencidos[0]
|
||||
if (!primero) return 0
|
||||
return Math.round(compararFechas(hoy, parseFechaLocal(primero.fechaVencimiento)) / 86400000)
|
||||
}
|
||||
|
||||
/** Status derivado: CANCELADO solo manual; LIQUIDADO si saldo == 0; si no, ACTIVO */
|
||||
export function statusPlan(plan: PlanPago): StatusPlan {
|
||||
if (plan.status === 'CANCELADO') return 'CANCELADO'
|
||||
return saldoPlan(plan) <= EPS ? 'LIQUIDADO' : 'ACTIVO'
|
||||
}
|
||||
|
||||
/** Al corriente = sin pagos VENCIDOS */
|
||||
export function alCorriente(plan: PlanPago, hoy: Date = new Date()): boolean {
|
||||
return !plan.pagos.some((p) => statusPago(p, hoy) === 'VENCIDO')
|
||||
}
|
||||
|
||||
/**
|
||||
* Mora simple de un pago: restante × (tasa% / 100) / 30 × días vencidos.
|
||||
* Solo aplica a pagos vencidos con saldo; un PARCIAL vencido también genera
|
||||
* mora sobre su restante. Tasa 0 (o negativa) = sin mora.
|
||||
*/
|
||||
export function moraDePago(pago: Pago, tasaMoraMensual: number, hoy: Date = new Date()): number {
|
||||
if (tasaMoraMensual <= 0) return 0
|
||||
const falta = restante(pago)
|
||||
if (falta <= EPS) return 0
|
||||
const diasVencido = Math.round(compararFechas(hoy, parseFechaLocal(pago.fechaVencimiento)) / 86400000)
|
||||
if (diasVencido <= 0) return 0
|
||||
return round2((falta * (tasaMoraMensual / 100) / 30) * diasVencido)
|
||||
}
|
||||
|
||||
/** Mora acumulada del plan a la fecha `hoy` (usa plan.tasaMoraMensual ?? 0) */
|
||||
export function totalMora(plan: PlanPago, hoy: Date = new Date()): number {
|
||||
const tasa = plan.tasaMoraMensual ?? 0
|
||||
return round2(plan.pagos.reduce((acc, p) => acc + moraDePago(p, tasa, hoy), 0))
|
||||
}
|
||||
|
||||
export interface CobranzaItem {
|
||||
plan: PlanPago
|
||||
pago: Pago
|
||||
/** Días de atraso (0 si aún no vence) */
|
||||
atraso: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Lista plana de "cobranza del día" a través de todos los planes ACTIVOS:
|
||||
* pagos vencidos (incluye PARCIAL vencido) más los que vencen en los próximos
|
||||
* `diasAnticipacion` días. Orden de urgencia: mayor atraso primero; a igual
|
||||
* atraso, el vencimiento más próximo primero.
|
||||
*/
|
||||
export function cobranzaDelDia(
|
||||
planes: PlanPago[],
|
||||
hoy: Date = new Date(),
|
||||
diasAnticipacion = 7,
|
||||
): CobranzaItem[] {
|
||||
const limite = new Date(hoy.getFullYear(), hoy.getMonth(), hoy.getDate() + diasAnticipacion)
|
||||
const items: CobranzaItem[] = []
|
||||
for (const plan of planes) {
|
||||
if (statusPlan(plan) !== 'ACTIVO') continue
|
||||
for (const pago of plan.pagos) {
|
||||
if (estaCubierto(pago)) continue
|
||||
if (compararFechas(parseFechaLocal(pago.fechaVencimiento), limite) > 0) continue
|
||||
const atraso = Math.max(
|
||||
0,
|
||||
Math.round(compararFechas(hoy, parseFechaLocal(pago.fechaVencimiento)) / 86400000),
|
||||
)
|
||||
items.push({ plan, pago, atraso })
|
||||
}
|
||||
}
|
||||
return items.sort((a, b) => {
|
||||
if (a.atraso !== b.atraso) return b.atraso - a.atraso
|
||||
return compararFechas(
|
||||
parseFechaLocal(a.pago.fechaVencimiento),
|
||||
parseFechaLocal(b.pago.fechaVencimiento),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Agrega una nota a la bitácora del plan (copia inmutable).
|
||||
* El texto es requerido (mín. 3 caracteres).
|
||||
*/
|
||||
export function agregarNotaAPlan(plan: PlanPago, texto: string): PlanPago {
|
||||
const limpio = texto.trim()
|
||||
if (limpio.length < 3) {
|
||||
throw new Error('La nota debe tener al menos 3 caracteres')
|
||||
}
|
||||
const nota: NotaPlan = { id: uuid(), fecha: new Date().toISOString(), texto: limpio }
|
||||
return { ...plan, notas: [...(plan.notas ?? []), nota] }
|
||||
}
|
||||
|
||||
export interface AbonoInput {
|
||||
pagoId: string
|
||||
fechaPago: string // ISO
|
||||
monto: number
|
||||
referencia: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplica un abono al pago indicado y devuelve una copia del plan actualizada.
|
||||
* Rechaza montos <= 0 y abonos que excedan el restante del pago.
|
||||
*/
|
||||
export function registrarAbono(plan: PlanPago, abono: AbonoInput): PlanPago {
|
||||
if (round2(abono.monto) <= 0) {
|
||||
throw new Error('El abono debe ser mayor a 0')
|
||||
}
|
||||
const pago = plan.pagos.find((p) => p.id === abono.pagoId)
|
||||
if (!pago) throw new Error('Pago no encontrado en el plan')
|
||||
const falta = restante(pago)
|
||||
// La validación es estricta al centavo: un abono nunca puede exceder el restante
|
||||
if (round2(abono.monto - falta) > 0) {
|
||||
throw new Error(`El abono excede el restante del pago (${falta.toFixed(2)})`)
|
||||
}
|
||||
const aplicacion: AplicacionPago = {
|
||||
id: uuid(),
|
||||
fechaPago: abono.fechaPago,
|
||||
monto: round2(abono.monto),
|
||||
referencia: abono.referencia,
|
||||
}
|
||||
const pagos = plan.pagos.map((p) =>
|
||||
p.id === abono.pagoId
|
||||
? { ...p, montoPagado: round2(p.montoPagado + abono.monto), aplicaciones: [...p.aplicaciones, aplicacion] }
|
||||
: p,
|
||||
)
|
||||
return { ...plan, pagos }
|
||||
}
|
||||
36
apps/cobranza/src/lib/formatters.ts
Normal file
36
apps/cobranza/src/lib/formatters.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
const formatoMXN = new Intl.NumberFormat('es-MX', {
|
||||
style: 'currency',
|
||||
currency: 'MXN',
|
||||
})
|
||||
|
||||
/** Formato monetario MXN, p. ej. $4,579,000.00 */
|
||||
export function formatoMoneda(valor: number): string {
|
||||
return formatoMXN.format(valor)
|
||||
}
|
||||
|
||||
/** Fracción (0.75) a texto porcentaje (75%) */
|
||||
export function formatoPorcentaje(fraccion: number): string {
|
||||
return `${(fraccion * 100).toLocaleString('es-MX', { maximumFractionDigits: 2 })}%`
|
||||
}
|
||||
|
||||
/** 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: "08/10/2027" */
|
||||
export function formatoFechaCorta(iso: string): string {
|
||||
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}`
|
||||
}
|
||||
49
apps/cobranza/src/lib/pdf/estadoCuenta.test.ts
Normal file
49
apps/cobranza/src/lib/pdf/estadoCuenta.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PlanPago } from '../../types'
|
||||
import { generarCalendario, registrarAbono } from '../cobranza'
|
||||
import { crearDocumento } from './estadoCuenta'
|
||||
|
||||
function planFixture(): PlanPago {
|
||||
const { mensualidad, pagos } = generarCalendario('2026-01-15', 100000, 3)
|
||||
let plan: PlanPago = {
|
||||
id: 'plan-fixture',
|
||||
ventaRef: 'VTA-2026-001',
|
||||
clienteNombre: 'Cliente Fixture',
|
||||
unidadCodigo: 'U-101',
|
||||
proyecto: 'SENDA',
|
||||
fechaInicio: '2026-01-15',
|
||||
engancheTotal: 100000,
|
||||
numPagos: 3,
|
||||
mensualidad,
|
||||
status: 'ACTIVO',
|
||||
pagos,
|
||||
notas: [],
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
plan = registrarAbono(plan, {
|
||||
pagoId: plan.pagos[0].id,
|
||||
fechaPago: '2026-01-15',
|
||||
monto: plan.pagos[0].monto,
|
||||
referencia: 'SPEI-001',
|
||||
})
|
||||
return plan
|
||||
}
|
||||
|
||||
describe('estadoCuenta PDF', () => {
|
||||
it('genera el documento con nombre de archivo correcto', () => {
|
||||
const hoy = new Date('2026-02-20T12:00:00')
|
||||
const { doc, nombreArchivo } = crearDocumento(planFixture(), hoy)
|
||||
expect(nombreArchivo).toBe('estado_cuenta_U-101_2026-02-20.pdf')
|
||||
expect(doc.getNumberOfPages()).toBeGreaterThanOrEqual(1)
|
||||
// smoke: el contenido interno es un PDF válido
|
||||
expect(doc.output('datauristring')).toContain('application/pdf')
|
||||
})
|
||||
|
||||
it('smoke: genera el documento con fila de mora cuando hay tasa y pagos vencidos', () => {
|
||||
const hoy = new Date('2026-04-20T12:00:00') // pagos 2 y 3 vencidos
|
||||
const plan: PlanPago = { ...planFixture(), tasaMoraMensual: 2 }
|
||||
const { doc } = crearDocumento(plan, hoy)
|
||||
expect(doc.getNumberOfPages()).toBeGreaterThanOrEqual(1)
|
||||
expect(doc.output('datauristring')).toContain('application/pdf')
|
||||
})
|
||||
})
|
||||
112
apps/cobranza/src/lib/pdf/estadoCuenta.ts
Normal file
112
apps/cobranza/src/lib/pdf/estadoCuenta.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { jsPDF } from 'jspdf'
|
||||
import autoTable from 'jspdf-autotable'
|
||||
import type { PlanPago } from '../../types'
|
||||
import { avancePlan, pagadoTotal, round2, saldoPlan, statusPago, totalMora } from '../cobranza'
|
||||
import { fechaISOHoy, formatoFechaCorta, formatoFechaLarga, formatoMoneda, formatoPorcentaje } from '../formatters'
|
||||
|
||||
const MARGEN = 14
|
||||
|
||||
// jspdf-autotable expone finalY en doc.lastAutoTable en runtime; sus tipos no lo declaran
|
||||
type DocConTabla = jsPDF & { lastAutoTable: { finalY: number } }
|
||||
const finalY = (doc: jsPDF): number => (doc as DocConTabla).lastAutoTable.finalY
|
||||
|
||||
export interface DocumentoPdf {
|
||||
doc: jsPDF
|
||||
nombreArchivo: string
|
||||
}
|
||||
|
||||
/** Construye el estado de cuenta del plan de enganche (sin descargarlo) */
|
||||
export function crearDocumento(plan: PlanPago, hoy: Date = new Date()): DocumentoPdf {
|
||||
const doc = new jsPDF()
|
||||
let y = MARGEN
|
||||
|
||||
// 1. Encabezado
|
||||
doc.setFontSize(14)
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.text('FRASA DESARROLLOS', 105, y, { align: 'center' })
|
||||
y += 7
|
||||
doc.setFontSize(11)
|
||||
doc.text('ESTADO DE CUENTA — PLAN DE ENGANCHE', 105, y, { align: 'center' })
|
||||
y += 6
|
||||
doc.setFontSize(9)
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.text(`Fecha de emisión: ${formatoFechaLarga(hoy)}`, 105, y, { align: 'center' })
|
||||
y += 8
|
||||
|
||||
// 2. Datos del plan
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 8 },
|
||||
head: [['CLIENTE', 'UNIDAD', 'PROYECTO', 'VENTA', 'ENGANCHE', 'MENSUALIDAD', 'PAGOS']],
|
||||
body: [[
|
||||
plan.clienteNombre,
|
||||
plan.unidadCodigo,
|
||||
plan.proyecto,
|
||||
plan.ventaRef || '—',
|
||||
formatoMoneda(plan.engancheTotal),
|
||||
formatoMoneda(plan.mensualidad),
|
||||
String(plan.numPagos),
|
||||
]],
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
})
|
||||
y = finalY(doc) + 6
|
||||
|
||||
// 3. Tabla de pagos
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 8 },
|
||||
head: [['#', 'VENCIMIENTO', 'MONTO', 'PAGADO', 'RESTANTE', 'STATUS', 'REFERENCIAS']],
|
||||
body: plan.pagos.map((p) => [
|
||||
String(p.numPago),
|
||||
formatoFechaCorta(p.fechaVencimiento),
|
||||
formatoMoneda(p.monto),
|
||||
formatoMoneda(p.montoPagado),
|
||||
formatoMoneda(round2(p.monto - p.montoPagado)),
|
||||
statusPago(p, hoy),
|
||||
p.aplicaciones
|
||||
.map((a) => `${a.referencia} (${formatoFechaCorta(a.fechaPago)})`)
|
||||
.join('\n') || '—',
|
||||
]),
|
||||
headStyles: { fillColor: [4, 120, 87] },
|
||||
})
|
||||
y = finalY(doc) + 6
|
||||
|
||||
// 4. Totales
|
||||
const pagado = pagadoTotal(plan)
|
||||
const saldo = saldoPlan(plan)
|
||||
const mora = totalMora(plan, hoy)
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 9 },
|
||||
body: [
|
||||
['TOTAL PAGADO', formatoMoneda(pagado)],
|
||||
['SALDO PENDIENTE', formatoMoneda(saldo)],
|
||||
...(mora > 0
|
||||
? [
|
||||
['MORA ACUMULADA', formatoMoneda(mora)],
|
||||
['TOTAL A PAGAR', formatoMoneda(round2(saldo + mora))],
|
||||
]
|
||||
: []),
|
||||
['% AVANCE', formatoPorcentaje(avancePlan(plan))],
|
||||
],
|
||||
theme: 'plain',
|
||||
columnStyles: { 0: { fontStyle: 'bold', cellWidth: 60 }, 1: { halign: 'left' } },
|
||||
})
|
||||
y = finalY(doc) + 10
|
||||
|
||||
// 5. Pie
|
||||
doc.setFontSize(8)
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.text('Documento informativo. Para aclaraciones contacta a tu asesor de ventas.', MARGEN, y)
|
||||
|
||||
return { doc, nombreArchivo: `estado_cuenta_${plan.unidadCodigo}_${fechaISOHoy(hoy)}.pdf` }
|
||||
}
|
||||
|
||||
/** Genera y descarga el estado de cuenta en PDF */
|
||||
export function descargar(plan: PlanPago): void {
|
||||
const { doc, nombreArchivo } = crearDocumento(plan)
|
||||
doc.save(nombreArchivo)
|
||||
}
|
||||
50
apps/cobranza/src/lib/pdf/reciboAbono.test.ts
Normal file
50
apps/cobranza/src/lib/pdf/reciboAbono.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { PlanPago } from '../../types'
|
||||
import { generarCalendario, registrarAbono } from '../cobranza'
|
||||
import { crearDocumento, folioRecibo } from './reciboAbono'
|
||||
|
||||
function planConAbono(): PlanPago {
|
||||
const { mensualidad, pagos } = generarCalendario('2026-01-15', 100000, 3)
|
||||
const plan: PlanPago = {
|
||||
id: 'plan-fixture',
|
||||
ventaRef: 'VTA-2026-001',
|
||||
clienteNombre: 'Cliente Fixture',
|
||||
clienteTelefono: '55 0000 0000',
|
||||
unidadCodigo: 'U-101',
|
||||
proyecto: 'SENDA',
|
||||
fechaInicio: '2026-01-15',
|
||||
engancheTotal: 100000,
|
||||
numPagos: 3,
|
||||
mensualidad,
|
||||
status: 'ACTIVO',
|
||||
pagos,
|
||||
notas: [],
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
}
|
||||
return registrarAbono(plan, {
|
||||
pagoId: plan.pagos[0].id,
|
||||
fechaPago: '2026-01-15',
|
||||
monto: 5000,
|
||||
referencia: 'SPEI-001',
|
||||
})
|
||||
}
|
||||
|
||||
describe('reciboAbono PDF', () => {
|
||||
it('el folio son los primeros 8 chars del id de la aplicación en mayúsculas', () => {
|
||||
const plan = planConAbono()
|
||||
const aplicacion = plan.pagos[0].aplicaciones[0]
|
||||
expect(folioRecibo(aplicacion)).toBe(aplicacion.id.slice(0, 8).toUpperCase())
|
||||
expect(folioRecibo({ ...aplicacion, id: 'abcdef12-3456-7890' })).toBe('ABCDEF12')
|
||||
})
|
||||
|
||||
it('genera el documento con nombre de archivo correcto', () => {
|
||||
const plan = planConAbono()
|
||||
const pago = plan.pagos[0]
|
||||
const aplicacion = pago.aplicaciones[0]
|
||||
const { doc, nombreArchivo } = crearDocumento(plan, pago, aplicacion, new Date('2026-02-20T12:00:00'))
|
||||
expect(nombreArchivo).toBe(`recibo_U-101_${folioRecibo(aplicacion)}.pdf`)
|
||||
expect(doc.getNumberOfPages()).toBeGreaterThanOrEqual(1)
|
||||
// smoke: el contenido interno es un PDF válido
|
||||
expect(doc.output('datauristring')).toContain('application/pdf')
|
||||
})
|
||||
})
|
||||
106
apps/cobranza/src/lib/pdf/reciboAbono.ts
Normal file
106
apps/cobranza/src/lib/pdf/reciboAbono.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { jsPDF } from 'jspdf'
|
||||
import autoTable from 'jspdf-autotable'
|
||||
import type { AplicacionPago, Pago, PlanPago } from '../../types'
|
||||
import { round2 } from '../cobranza'
|
||||
import { formatoFechaCorta, formatoFechaLarga, formatoMoneda } from '../formatters'
|
||||
import type { DocumentoPdf } from './estadoCuenta'
|
||||
|
||||
const MARGEN = 14
|
||||
|
||||
/** Folio corto del recibo: primeros 8 caracteres del id de la aplicación, en mayúsculas */
|
||||
export function folioRecibo(aplicacion: AplicacionPago): string {
|
||||
return aplicacion.id.slice(0, 8).toUpperCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye el recibo de pago de una aplicación (abono) ya registrada.
|
||||
* El restante del pago y el saldo del plan se calculan acumulando las
|
||||
* aplicaciones registradas hasta esa aplicación (inclusive), de modo que el
|
||||
* recibo refleja los montos vigentes justo tras el abono.
|
||||
*/
|
||||
export function crearDocumento(
|
||||
plan: PlanPago,
|
||||
pago: Pago,
|
||||
aplicacion: AplicacionPago,
|
||||
hoy: Date = new Date(),
|
||||
): DocumentoPdf {
|
||||
const folio = folioRecibo(aplicacion)
|
||||
const doc = new jsPDF()
|
||||
let y = MARGEN
|
||||
|
||||
// Aplicado acumulado (plan y pago) hasta la aplicación indicada, en orden de registro
|
||||
let aplicadoPlan = 0
|
||||
let aplicadoPago = 0
|
||||
let encontrada = false
|
||||
for (const p of plan.pagos) {
|
||||
for (const a of p.aplicaciones) {
|
||||
aplicadoPlan += a.monto
|
||||
if (p.id === pago.id) aplicadoPago += a.monto
|
||||
if (a.id === aplicacion.id) {
|
||||
encontrada = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (encontrada) break
|
||||
}
|
||||
const saldoTrasAbono = round2(plan.engancheTotal - aplicadoPlan)
|
||||
const restanteTrasAbono = round2(pago.monto - aplicadoPago)
|
||||
|
||||
// 1. Encabezado
|
||||
doc.setFontSize(14)
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.text('FRASA DESARROLLOS', 105, y, { align: 'center' })
|
||||
y += 7
|
||||
doc.setFontSize(11)
|
||||
doc.text('RECIBO DE PAGO', 105, y, { align: 'center' })
|
||||
y += 6
|
||||
doc.setFontSize(9)
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.text(`Folio: ${folio} · Fecha de emisión: ${formatoFechaLarga(hoy)}`, 105, y, { align: 'center' })
|
||||
y += 8
|
||||
|
||||
// 2. Datos del recibo
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 9 },
|
||||
body: [
|
||||
['CLIENTE', plan.clienteNombre],
|
||||
['UNIDAD', plan.unidadCodigo],
|
||||
['PROYECTO', plan.proyecto],
|
||||
['PAGO', `#${pago.numPago} (vencimiento ${formatoFechaCorta(pago.fechaVencimiento)})`],
|
||||
['FECHA DE PAGO', formatoFechaCorta(aplicacion.fechaPago)],
|
||||
['MONTO DEL ABONO', formatoMoneda(aplicacion.monto)],
|
||||
['REFERENCIA', aplicacion.referencia || '—'],
|
||||
['RESTANTE DEL PAGO', formatoMoneda(restanteTrasAbono)],
|
||||
['SALDO DEL PLAN TRAS EL ABONO', formatoMoneda(saldoTrasAbono)],
|
||||
],
|
||||
theme: 'plain',
|
||||
columnStyles: { 0: { fontStyle: 'bold', cellWidth: 70 }, 1: { halign: 'left' } },
|
||||
})
|
||||
y = (doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable.finalY + 10
|
||||
|
||||
// 3. Leyenda informativa
|
||||
doc.setFontSize(8)
|
||||
doc.text(
|
||||
'Recibo informativo del abono aplicado al plan de enganche. No sustituye la factura ni el comprobante fiscal correspondiente.',
|
||||
MARGEN,
|
||||
y,
|
||||
{ maxWidth: 180 },
|
||||
)
|
||||
y += 24
|
||||
|
||||
// 4. Línea de firma
|
||||
doc.setFontSize(9)
|
||||
doc.text('_______________________________', 105, y, { align: 'center' })
|
||||
y += 5
|
||||
doc.text(`Recibió — ${plan.clienteNombre}`, 105, y, { align: 'center' })
|
||||
|
||||
return { doc, nombreArchivo: `recibo_${plan.unidadCodigo}_${folio}.pdf` }
|
||||
}
|
||||
|
||||
/** Genera y descarga el recibo de pago en PDF */
|
||||
export function descargarRecibo(plan: PlanPago, pago: Pago, aplicacion: AplicacionPago): void {
|
||||
const { doc, nombreArchivo } = crearDocumento(plan, pago, aplicacion)
|
||||
doc.save(nombreArchivo)
|
||||
}
|
||||
14
apps/cobranza/src/lib/uuid.ts
Normal file
14
apps/cobranza/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)
|
||||
})
|
||||
}
|
||||
29
apps/cobranza/src/main.tsx
Normal file
29
apps/cobranza/src/main.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
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 Layout from './components/Layout'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import HoyPage from './pages/HoyPage'
|
||||
import PlanDetallePage from './pages/PlanDetallePage'
|
||||
import NuevoPlanPage from './pages/NuevoPlanPage'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter basename="/pay">
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
<Route path="/hoy" element={<HoyPage />} />
|
||||
<Route path="/planes/:id" element={<PlanDetallePage />} />
|
||||
<Route path="/nuevo" element={<NuevoPlanPage />} />
|
||||
<Route path="*" element={<DashboardPage />} />
|
||||
</Routes>
|
||||
</Layout>
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
)
|
||||
359
apps/cobranza/src/pages/DashboardPage.tsx
Normal file
359
apps/cobranza/src/pages/DashboardPage.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link, useSearchParams } from 'react-router-dom'
|
||||
import {
|
||||
Bar,
|
||||
BarChart,
|
||||
CartesianGrid,
|
||||
Cell,
|
||||
Legend,
|
||||
Pie,
|
||||
PieChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from 'recharts'
|
||||
import type { PlanPago, StatusPlan } from '../types'
|
||||
import { cargarPlanes } from '../services/repository'
|
||||
import {
|
||||
alCorriente,
|
||||
avancePlan,
|
||||
diasAtraso,
|
||||
parseFechaLocal,
|
||||
proximoVencimiento,
|
||||
round2,
|
||||
saldoPlan,
|
||||
statusPlan,
|
||||
sumarMeses,
|
||||
} from '../lib/cobranza'
|
||||
import { formatoFechaCorta, formatoMoneda, formatoPorcentaje } from '../lib/formatters'
|
||||
import KpiCard from '../components/KpiCard'
|
||||
import SemaforoDot, { semaforoPlan } from '../components/Semaforo'
|
||||
|
||||
const MESES_CORTOS = ['ene', 'feb', 'mar', 'abr', 'may', 'jun', 'jul', 'ago', 'sep', 'oct', 'nov', 'dic']
|
||||
const COLORES_PIE = ['#1B4332', '#d4d4d4']
|
||||
|
||||
type ColumnaOrden = 'saldo' | 'vencimiento' | 'atraso' | 'avance'
|
||||
const COLUMNAS_ORDEN: ColumnaOrden[] = ['saldo', 'vencimiento', 'atraso', 'avance']
|
||||
|
||||
function esColumnaOrden(valor: string | null): valor is ColumnaOrden {
|
||||
return valor !== null && (COLUMNAS_ORDEN as string[]).includes(valor)
|
||||
}
|
||||
|
||||
function claveMes(fecha: Date): string {
|
||||
return `${fecha.getFullYear()}-${String(fecha.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [planes] = useState<PlanPago[]>(() => cargarPlanes())
|
||||
// Filtros y ordenamiento persistidos en la URL para que sean compartibles
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const busqueda = searchParams.get('q') ?? ''
|
||||
const filtroStatus = (searchParams.get('status') ?? 'TODOS') as StatusPlan | 'TODOS'
|
||||
const filtroProyecto = searchParams.get('proyecto') ?? 'TODOS'
|
||||
const paramOrden = searchParams.get('sort')
|
||||
const orden: ColumnaOrden | null = esColumnaOrden(paramOrden) ? paramOrden : null
|
||||
const dir: 'asc' | 'desc' = searchParams.get('dir') === 'desc' ? 'desc' : 'asc'
|
||||
const hoy = useMemo(() => new Date(), [])
|
||||
|
||||
const actualizarParams = (cambios: Record<string, string | null>) => {
|
||||
setSearchParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev)
|
||||
for (const [clave, valor] of Object.entries(cambios)) {
|
||||
if (valor === null || valor === '') next.delete(clave)
|
||||
else next.set(clave, valor)
|
||||
}
|
||||
return next
|
||||
},
|
||||
{ replace: true },
|
||||
)
|
||||
}
|
||||
|
||||
const alternarOrden = (columna: ColumnaOrden) => {
|
||||
// Click sobre la columna activa alterna asc/desc; sobre otra, empieza en asc
|
||||
if (orden === columna) {
|
||||
actualizarParams({ sort: columna, dir: dir === 'asc' ? 'desc' : 'asc' })
|
||||
} else {
|
||||
actualizarParams({ sort: columna, dir: 'asc' })
|
||||
}
|
||||
}
|
||||
|
||||
const proyectos = useMemo(
|
||||
() => [...new Set(planes.map((p) => p.proyecto))].sort((a, b) => a.localeCompare(b)),
|
||||
[planes],
|
||||
)
|
||||
|
||||
const derivados = useMemo(
|
||||
() =>
|
||||
planes.map((plan) => ({
|
||||
plan,
|
||||
status: statusPlan(plan),
|
||||
saldo: saldoPlan(plan),
|
||||
avance: avancePlan(plan),
|
||||
corriente: alCorriente(plan, hoy),
|
||||
proximo: proximoVencimiento(plan, hoy),
|
||||
atraso: diasAtraso(plan, hoy),
|
||||
semaforo: semaforoPlan(plan, hoy),
|
||||
})),
|
||||
[planes, hoy],
|
||||
)
|
||||
|
||||
const activos = derivados.filter((d) => d.status === 'ACTIVO')
|
||||
const alCorrienteCount = activos.filter((d) => d.corriente).length
|
||||
const conAtraso = activos.length - alCorrienteCount
|
||||
const saldoTotal = round2(
|
||||
derivados.filter((d) => d.status !== 'CANCELADO').reduce((acc, d) => acc + d.saldo, 0),
|
||||
)
|
||||
|
||||
const mesActual = claveMes(hoy)
|
||||
const cobradoEsteMes = round2(
|
||||
planes
|
||||
.flatMap((p) => p.pagos.flatMap((pg) => pg.aplicaciones))
|
||||
.filter((a) => a.fechaPago.startsWith(mesActual))
|
||||
.reduce((acc, a) => acc + a.monto, 0),
|
||||
)
|
||||
|
||||
const datosPie = [
|
||||
{ name: 'Al corriente', value: alCorrienteCount },
|
||||
{ name: 'Con atraso', value: conAtraso },
|
||||
]
|
||||
|
||||
const datosBarras = useMemo(() => {
|
||||
const aplicaciones = planes.flatMap((p) => p.pagos.flatMap((pg) => pg.aplicaciones))
|
||||
const meses: { clave: string; etiqueta: string }[] = []
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
const f = sumarMeses(new Date(hoy.getFullYear(), hoy.getMonth(), 1), -i)
|
||||
meses.push({ clave: claveMes(f), etiqueta: MESES_CORTOS[f.getMonth()] })
|
||||
}
|
||||
return meses.map(({ clave, etiqueta }) => ({
|
||||
mes: etiqueta,
|
||||
cobrado: round2(
|
||||
aplicaciones.filter((a) => a.fechaPago.startsWith(clave)).reduce((acc, a) => acc + a.monto, 0),
|
||||
),
|
||||
}))
|
||||
}, [planes, hoy])
|
||||
|
||||
const filtrados = derivados.filter((d) => {
|
||||
if (filtroStatus !== 'TODOS' && d.status !== filtroStatus) return false
|
||||
if (filtroProyecto !== 'TODOS' && d.plan.proyecto !== filtroProyecto) return false
|
||||
const texto = busqueda.trim().toLowerCase()
|
||||
if (!texto) return true
|
||||
return (
|
||||
d.plan.clienteNombre.toLowerCase().includes(texto) ||
|
||||
d.plan.unidadCodigo.toLowerCase().includes(texto)
|
||||
)
|
||||
})
|
||||
|
||||
const valorOrden = (d: (typeof filtrados)[number]): number => {
|
||||
switch (orden) {
|
||||
case 'saldo':
|
||||
return d.saldo
|
||||
case 'vencimiento':
|
||||
// Planes cubiertos (sin próximo vencimiento) siempre al final
|
||||
return d.proximo ? parseFechaLocal(d.proximo.fechaVencimiento).getTime() : Number.MAX_SAFE_INTEGER
|
||||
case 'atraso':
|
||||
return d.atraso
|
||||
case 'avance':
|
||||
return d.avance
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
const ordenados = (() => {
|
||||
if (!orden) return filtrados
|
||||
const factor = dir === 'desc' ? -1 : 1
|
||||
return [...filtrados].sort((a, b) => (valorOrden(a) - valorOrden(b)) * factor)
|
||||
})()
|
||||
|
||||
const encabezadoOrdenable = (columna: ColumnaOrden, titulo: string, alineadoDerecha = false) => (
|
||||
<th scope="col" className={`py-3 pr-4 ${alineadoDerecha ? 'text-right' : ''}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => alternarOrden(columna)}
|
||||
aria-label={`Ordenar por ${titulo}`}
|
||||
className="inline-flex items-center gap-1 font-bold uppercase tracking-widest transition-colors duration-150 hover:text-neutral-900"
|
||||
>
|
||||
{titulo}
|
||||
{orden === columna && <span aria-hidden="true">{dir === 'asc' ? '↑' : '↓'}</span>}
|
||||
</button>
|
||||
</th>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-16">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-light text-neutral-900">Módulo Cobranza — Planes de enganche</h1>
|
||||
<Link
|
||||
to="/nuevo"
|
||||
className="bg-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:opacity-90"
|
||||
>
|
||||
Nuevo plan
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* KPIs */}
|
||||
<div className="grid grid-cols-2 gap-6 lg:grid-cols-4">
|
||||
<KpiCard titulo="Planes activos" valor={activos.length} />
|
||||
<KpiCard
|
||||
titulo="Al corriente / con atraso"
|
||||
valor={`${alCorrienteCount} / ${conAtraso}`}
|
||||
detalle="Solo planes activos"
|
||||
/>
|
||||
<KpiCard titulo="Saldo por cobrar" valor={formatoMoneda(saldoTotal)} />
|
||||
<KpiCard titulo="Cobrado este mes" valor={formatoMoneda(cobradoEsteMes)} />
|
||||
</div>
|
||||
|
||||
{/* Gráficas */}
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<div className="border border-neutral-200 bg-white p-6">
|
||||
<h2 className="mb-6 text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
Al corriente vs vencidos
|
||||
</h2>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={datosPie}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
innerRadius={50}
|
||||
outerRadius={80}
|
||||
strokeWidth={0}
|
||||
label
|
||||
>
|
||||
{datosPie.map((_, i) => (
|
||||
<Cell key={i} fill={COLORES_PIE[i % COLORES_PIE.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="border border-neutral-200 bg-white p-6">
|
||||
<h2 className="mb-6 text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
Cobrado por mes (últimos 6 meses)
|
||||
</h2>
|
||||
<ResponsiveContainer width="100%" height={240}>
|
||||
<BarChart data={datosBarras}>
|
||||
<CartesianGrid stroke="#e5e5e5" strokeDasharray="3 3" vertical={false} />
|
||||
<XAxis dataKey="mes" tickLine={false} axisLine={{ stroke: '#e5e5e5' }} />
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(v: number) => `$${(v / 1000).toLocaleString('es-MX')}k`}
|
||||
/>
|
||||
<Tooltip formatter={(v) => formatoMoneda(Number(v))} cursor={{ fill: '#fafafa' }} />
|
||||
<Bar dataKey="cobrado" fill="#1B4332" name="Cobrado" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filtros */}
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<label htmlFor="filtro-busqueda" className="sr-only">
|
||||
Buscar por cliente o unidad
|
||||
</label>
|
||||
<input
|
||||
id="filtro-busqueda"
|
||||
type="search"
|
||||
value={busqueda}
|
||||
onChange={(e) => actualizarParams({ q: e.target.value })}
|
||||
placeholder="Buscar por cliente o unidad…"
|
||||
className="w-64 border border-neutral-300 bg-white px-3 py-2.5 text-sm transition-colors duration-150 focus:border-neutral-900 focus:outline-none"
|
||||
/>
|
||||
<label htmlFor="filtro-status" className="sr-only">
|
||||
Filtrar por status
|
||||
</label>
|
||||
<select
|
||||
id="filtro-status"
|
||||
value={filtroStatus}
|
||||
onChange={(e) => actualizarParams({ status: e.target.value === 'TODOS' ? null : e.target.value })}
|
||||
className="border border-neutral-300 bg-white px-3 py-2.5 text-sm transition-colors duration-150 focus:border-neutral-900 focus:outline-none"
|
||||
>
|
||||
<option value="TODOS">Todos los status</option>
|
||||
<option value="ACTIVO">ACTIVO</option>
|
||||
<option value="LIQUIDADO">LIQUIDADO</option>
|
||||
<option value="CANCELADO">CANCELADO</option>
|
||||
</select>
|
||||
<label htmlFor="filtro-proyecto" className="sr-only">
|
||||
Filtrar por proyecto
|
||||
</label>
|
||||
<select
|
||||
id="filtro-proyecto"
|
||||
value={filtroProyecto}
|
||||
onChange={(e) => actualizarParams({ proyecto: e.target.value === 'TODOS' ? null : e.target.value })}
|
||||
className="border border-neutral-300 bg-white px-3 py-2.5 text-sm transition-colors duration-150 focus:border-neutral-900 focus:outline-none"
|
||||
>
|
||||
<option value="TODOS">Todos los proyectos</option>
|
||||
{proyectos.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Tabla de planes */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-neutral-900 text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
<th scope="col" className="py-3 pr-4">Cliente</th>
|
||||
<th scope="col" className="py-3 pr-4">Unidad</th>
|
||||
<th scope="col" className="py-3 pr-4">Proyecto</th>
|
||||
{encabezadoOrdenable('avance', '% pagado')}
|
||||
{encabezadoOrdenable('vencimiento', 'Próx. vencimiento')}
|
||||
{encabezadoOrdenable('saldo', 'Saldo', true)}
|
||||
{encabezadoOrdenable('atraso', 'Días atraso', true)}
|
||||
<th scope="col" className="py-3">Estado</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{ordenados.map((d) => (
|
||||
<tr key={d.plan.id} className="border-b border-neutral-200 transition-colors duration-150 hover:bg-neutral-50">
|
||||
<td className="py-3 pr-4">
|
||||
<Link to={`/planes/${d.plan.id}`} className="font-medium text-accent hover:underline">
|
||||
{d.plan.clienteNombre}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-3 pr-4">{d.plan.unidadCodigo}</td>
|
||||
<td className="py-3 pr-4">{d.plan.proyecto}</td>
|
||||
<td className="py-3 pr-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1 w-24 bg-neutral-200">
|
||||
<div
|
||||
className="h-full bg-accent"
|
||||
style={{ width: `${Math.min(100, d.avance * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-neutral-500">{formatoPorcentaje(d.avance)}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
{d.proximo ? formatoFechaCorta(d.proximo.fechaVencimiento) : '—'}
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-right font-medium">{formatoMoneda(d.saldo)}</td>
|
||||
<td className="py-3 pr-4 text-right">
|
||||
{d.atraso > 0 ? <span className="text-red-700">{d.atraso}</span> : '0'}
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<SemaforoDot valor={d.semaforo} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{ordenados.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="py-8 text-center text-neutral-500">
|
||||
No hay planes que coincidan con el filtro.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
102
apps/cobranza/src/pages/HoyPage.tsx
Normal file
102
apps/cobranza/src/pages/HoyPage.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import type { PlanPago } from '../types'
|
||||
import { cargarPlanes } from '../services/repository'
|
||||
import { cobranzaDelDia, round2, statusPago } from '../lib/cobranza'
|
||||
import { formatoFechaCorta, formatoMoneda } from '../lib/formatters'
|
||||
import KpiCard from '../components/KpiCard'
|
||||
|
||||
/** Vista "Cobranza del día": vencidos + por vencer en 7 días, a través de todos los planes activos */
|
||||
export default function HoyPage() {
|
||||
const [planes] = useState<PlanPago[]>(() => cargarPlanes())
|
||||
const hoy = useMemo(() => new Date(), [])
|
||||
const items = useMemo(() => cobranzaDelDia(planes, hoy), [planes, hoy])
|
||||
|
||||
const vencidos = items.filter((i) => i.atraso > 0)
|
||||
const porVencer = items.length - vencidos.length
|
||||
const totalPorCobrar = round2(
|
||||
items.reduce((acc, i) => acc + (i.pago.monto - i.pago.montoPagado), 0),
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="space-y-16">
|
||||
<div>
|
||||
<h1 className="text-3xl font-light text-neutral-900">Cobranza del día</h1>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
Pagos vencidos y vencimientos de los próximos 7 días, en todos los planes activos.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6 lg:grid-cols-3">
|
||||
<KpiCard titulo="Pagos vencidos" valor={vencidos.length} />
|
||||
<KpiCard titulo="Por vencer (7 días)" valor={porVencer} />
|
||||
<KpiCard titulo="Monto por cobrar" valor={formatoMoneda(totalPorCobrar)} />
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-neutral-900 text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
<th scope="col" className="py-3 pr-4">Cliente</th>
|
||||
<th scope="col" className="py-3 pr-4">Teléfono</th>
|
||||
<th scope="col" className="py-3 pr-4">Unidad</th>
|
||||
<th scope="col" className="py-3 pr-4"># pago</th>
|
||||
<th scope="col" className="py-3 pr-4">Vencimiento</th>
|
||||
<th scope="col" className="py-3 pr-4 text-right">Monto</th>
|
||||
<th scope="col" className="py-3 pr-4 text-right">Restante</th>
|
||||
<th scope="col" className="py-3 pr-4 text-right">Días atraso</th>
|
||||
<th scope="col" className="py-3">Plan</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map(({ plan, pago, atraso }) => (
|
||||
<tr
|
||||
key={`${plan.id}-${pago.id}`}
|
||||
className="border-b border-neutral-200 transition-colors duration-150 hover:bg-neutral-50"
|
||||
>
|
||||
<td className="py-3 pr-4 font-medium">{plan.clienteNombre}</td>
|
||||
<td className="py-3 pr-4">
|
||||
{plan.clienteTelefono ? (
|
||||
<a href={`tel:${plan.clienteTelefono.replace(/\s/g, '')}`} className="text-accent hover:underline">
|
||||
{plan.clienteTelefono}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-neutral-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 pr-4">{plan.unidadCodigo}</td>
|
||||
<td className="py-3 pr-4">{pago.numPago}</td>
|
||||
<td className="py-3 pr-4">{formatoFechaCorta(pago.fechaVencimiento)}</td>
|
||||
<td className="py-3 pr-4 text-right">{formatoMoneda(pago.monto)}</td>
|
||||
<td className="py-3 pr-4 text-right font-medium">
|
||||
{formatoMoneda(round2(pago.monto - pago.montoPagado))}
|
||||
</td>
|
||||
<td className="py-3 pr-4 text-right">
|
||||
{atraso > 0 ? (
|
||||
<span className="font-medium text-red-700">{atraso}</span>
|
||||
) : (
|
||||
<span className="text-amber-700">
|
||||
{statusPago(pago, hoy) === 'PARCIAL' ? 'Parcial' : 'Por vencer'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3">
|
||||
<Link to={`/planes/${plan.id}`} className="text-accent hover:underline">
|
||||
Ver plan
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={9} className="py-8 text-center text-neutral-500">
|
||||
No hay pagos vencidos ni vencimientos en los próximos 7 días.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
213
apps/cobranza/src/pages/NuevoPlanPage.tsx
Normal file
213
apps/cobranza/src/pages/NuevoPlanPage.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { crearPlan } from '../services/repository'
|
||||
import { generarCalendario } from '../lib/cobranza'
|
||||
import { fechaISOHoy, formatoFechaCorta, formatoMoneda } from '../lib/formatters'
|
||||
|
||||
const PROYECTOS = ['RESIDENCIAL MONTEBELLO', 'SENDA', 'DIAGONAL'] as const
|
||||
|
||||
const esquema = z.object({
|
||||
clienteNombre: z.string().trim().min(3, 'Nombre del cliente requerido (mín. 3 caracteres)'),
|
||||
clienteTelefono: z.string().trim().optional(),
|
||||
unidadCodigo: z.string().trim().min(1, 'Código de unidad requerido'),
|
||||
proyecto: z.enum(PROYECTOS),
|
||||
ventaRef: z.string().trim(),
|
||||
fechaInicio: z.string().min(1, 'Fecha de inicio requerida'),
|
||||
engancheTotal: z
|
||||
.number({ invalid_type_error: 'Captura un monto válido' })
|
||||
.positive('El enganche debe ser mayor a 0'),
|
||||
numPagos: z
|
||||
.number({ invalid_type_error: 'Captura un número válido' })
|
||||
.int('Debe ser un número entero')
|
||||
.min(1, 'Mínimo 1 pago')
|
||||
.max(36, 'Máximo 36 pagos'),
|
||||
tasaMoraMensual: z.preprocess(
|
||||
(v) => (v === '' || v === undefined || v === null ? undefined : Number(v)),
|
||||
z
|
||||
.number({ invalid_type_error: 'Captura una tasa válida' })
|
||||
.min(0, 'La tasa no puede ser negativa')
|
||||
.max(30, 'Tasa máxima 30%')
|
||||
.optional(),
|
||||
),
|
||||
})
|
||||
|
||||
type NuevoPlanForm = z.infer<typeof esquema>
|
||||
|
||||
const claseInput =
|
||||
'mt-1 w-full border border-neutral-300 bg-white px-3 py-2.5 text-sm transition-colors duration-150 focus:border-neutral-900 focus:outline-none'
|
||||
const claseError = 'mt-1 text-xs text-red-700'
|
||||
const claseLabel = 'block text-xs font-bold uppercase tracking-widest text-neutral-500'
|
||||
|
||||
export default function NuevoPlanPage() {
|
||||
const navigate = useNavigate()
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm<NuevoPlanForm>({
|
||||
resolver: zodResolver(esquema),
|
||||
defaultValues: {
|
||||
clienteNombre: '',
|
||||
unidadCodigo: '',
|
||||
proyecto: 'SENDA',
|
||||
ventaRef: '',
|
||||
fechaInicio: fechaISOHoy(),
|
||||
numPagos: 6,
|
||||
},
|
||||
})
|
||||
|
||||
const [fechaInicio, engancheTotal, numPagos] = watch(['fechaInicio', 'engancheTotal', 'numPagos'])
|
||||
|
||||
// Vista previa en vivo del calendario; solo si los valores son válidos
|
||||
let vistaPrevia: ReturnType<typeof generarCalendario> | null = null
|
||||
try {
|
||||
if (fechaInicio && engancheTotal > 0 && Number.isInteger(numPagos) && numPagos >= 1 && numPagos <= 36) {
|
||||
vistaPrevia = generarCalendario(fechaInicio, engancheTotal, numPagos)
|
||||
}
|
||||
} catch {
|
||||
vistaPrevia = null
|
||||
}
|
||||
|
||||
const guardar = (datos: NuevoPlanForm) => {
|
||||
const plan = crearPlan(datos)
|
||||
void navigate(`/planes/${plan.id}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-16">
|
||||
<h1 className="text-3xl font-light text-neutral-900">Nuevo plan de enganche</h1>
|
||||
|
||||
<form
|
||||
className="border border-neutral-200 bg-white p-8"
|
||||
onSubmit={(e) => {
|
||||
void handleSubmit(guardar)(e)
|
||||
}}
|
||||
>
|
||||
<div className="grid gap-6 sm:grid-cols-2">
|
||||
<div className="sm:col-span-2">
|
||||
<label htmlFor="clienteNombre" className={claseLabel}>Cliente *</label>
|
||||
<input id="clienteNombre" type="text" className={claseInput} {...register('clienteNombre')} />
|
||||
{errors.clienteNombre && <p className={claseError}>{errors.clienteNombre.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="clienteTelefono" className={claseLabel}>Teléfono de contacto</label>
|
||||
<input
|
||||
id="clienteTelefono"
|
||||
type="tel"
|
||||
className={claseInput}
|
||||
placeholder="55 1234 5678"
|
||||
{...register('clienteTelefono')}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="unidadCodigo" className={claseLabel}>Unidad *</label>
|
||||
<input id="unidadCodigo" type="text" className={claseInput} placeholder="U-101" {...register('unidadCodigo')} />
|
||||
{errors.unidadCodigo && <p className={claseError}>{errors.unidadCodigo.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="proyecto" className={claseLabel}>Proyecto *</label>
|
||||
<select id="proyecto" className={claseInput} {...register('proyecto')}>
|
||||
{PROYECTOS.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="ventaRef" className={claseLabel}>Referencia de venta</label>
|
||||
<input id="ventaRef" type="text" className={claseInput} placeholder="VTA-2026-001" {...register('ventaRef')} />
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="fechaInicio" className={claseLabel}>Primer vencimiento *</label>
|
||||
<input id="fechaInicio" type="date" className={claseInput} {...register('fechaInicio')} />
|
||||
{errors.fechaInicio && <p className={claseError}>{errors.fechaInicio.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="engancheTotal" className={claseLabel}>Enganche total (MXN) *</label>
|
||||
<input
|
||||
id="engancheTotal"
|
||||
type="number"
|
||||
step="0.01"
|
||||
className={claseInput}
|
||||
{...register('engancheTotal', { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.engancheTotal && <p className={claseError}>{errors.engancheTotal.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="numPagos" className={claseLabel}>Número de pagos *</label>
|
||||
<input
|
||||
id="numPagos"
|
||||
type="number"
|
||||
step="1"
|
||||
className={claseInput}
|
||||
{...register('numPagos', { valueAsNumber: true })}
|
||||
/>
|
||||
{errors.numPagos && <p className={claseError}>{errors.numPagos.message}</p>}
|
||||
</div>
|
||||
<div>
|
||||
<label htmlFor="tasaMoraMensual" className={claseLabel}>Tasa de mora mensual (%)</label>
|
||||
<input
|
||||
id="tasaMoraMensual"
|
||||
type="number"
|
||||
step="0.1"
|
||||
min="0"
|
||||
className={claseInput}
|
||||
placeholder="0 = sin mora"
|
||||
{...register('tasaMoraMensual')}
|
||||
/>
|
||||
{errors.tasaMoraMensual && <p className={claseError}>{errors.tasaMoraMensual.message}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Vista previa del calendario */}
|
||||
{vistaPrevia && (
|
||||
<div className="mt-8 border border-neutral-200 bg-white p-6">
|
||||
<h2 className="text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
Vista previa — {vistaPrevia.pagos.length} pagos de {formatoMoneda(vistaPrevia.mensualidad)}
|
||||
</h2>
|
||||
<div className="mt-4 max-h-48 overflow-y-auto">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead className="text-neutral-500">
|
||||
<tr className="border-b border-neutral-900">
|
||||
<th scope="col" className="py-1">#</th>
|
||||
<th scope="col" className="py-1">Vencimiento</th>
|
||||
<th scope="col" className="py-1 text-right">Monto</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{vistaPrevia.pagos.map((p) => (
|
||||
<tr key={p.numPago} className="border-b border-neutral-200">
|
||||
<td className="py-1">{p.numPago}</td>
|
||||
<td className="py-1">{formatoFechaCorta(p.fechaVencimiento)}</td>
|
||||
<td className="py-1 text-right">{formatoMoneda(p.monto)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-8 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void navigate('/')}
|
||||
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"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="bg-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
Guardar plan
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
324
apps/cobranza/src/pages/PlanDetallePage.tsx
Normal file
324
apps/cobranza/src/pages/PlanDetallePage.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
import { Fragment, useMemo, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import type { Pago, PlanPago } from '../types'
|
||||
import { agregarNota, cancelarPlan, obtenerPlan, registrarAbonoEnPlan } from '../services/repository'
|
||||
import {
|
||||
avancePlan,
|
||||
diasAtraso,
|
||||
moraDePago,
|
||||
pagadoTotal,
|
||||
round2,
|
||||
saldoPlan,
|
||||
statusPago,
|
||||
statusPlan,
|
||||
totalMora,
|
||||
} from '../lib/cobranza'
|
||||
import { formatoFechaCorta, formatoMoneda, formatoPorcentaje } from '../lib/formatters'
|
||||
import { descargar } from '../lib/pdf/estadoCuenta'
|
||||
import { descargarRecibo } from '../lib/pdf/reciboAbono'
|
||||
import BadgeStatus from '../components/BadgeStatus'
|
||||
import KpiCard from '../components/KpiCard'
|
||||
import ModalAbono from '../components/ModalAbono'
|
||||
|
||||
export default function PlanDetallePage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [plan, setPlan] = useState<PlanPago | undefined>(() => (id ? obtenerPlan(id) : undefined))
|
||||
const [pagoAbono, setPagoAbono] = useState<Pago | null>(null)
|
||||
const [expandidos, setExpandidos] = useState<Set<string>>(new Set())
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [notaTexto, setNotaTexto] = useState('')
|
||||
const [errorNota, setErrorNota] = useState<string | null>(null)
|
||||
const hoy = useMemo(() => new Date(), [])
|
||||
|
||||
if (!plan) {
|
||||
return (
|
||||
<div className="border border-neutral-200 bg-white p-16 text-center">
|
||||
<p className="text-neutral-500">Plan no encontrado.</p>
|
||||
<Link to="/" className="mt-4 inline-block text-accent hover:underline">
|
||||
Volver al dashboard
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const status = statusPlan(plan)
|
||||
const saldo = saldoPlan(plan)
|
||||
const atraso = diasAtraso(plan, hoy)
|
||||
const tasaMora = plan.tasaMoraMensual ?? 0
|
||||
const mora = totalMora(plan, hoy)
|
||||
|
||||
const toggleExpandido = (pagoId: string) => {
|
||||
setExpandidos((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(pagoId)) next.delete(pagoId)
|
||||
else next.add(pagoId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const guardarAbono = (datos: { monto: number; fechaPago: string; referencia: string }) => {
|
||||
if (!pagoAbono) return
|
||||
try {
|
||||
const actualizado = registrarAbonoEnPlan(plan.id, datos, pagoAbono.id)
|
||||
setPlan(actualizado)
|
||||
setPagoAbono(null)
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Error al registrar el abono')
|
||||
}
|
||||
}
|
||||
|
||||
const cancelar = () => {
|
||||
if (!window.confirm('¿Cancelar este plan? El saldo pendiente dejará de cobrarse.')) return
|
||||
setPlan(cancelarPlan(plan.id))
|
||||
}
|
||||
|
||||
const guardarNota = () => {
|
||||
try {
|
||||
setPlan(agregarNota(plan.id, notaTexto))
|
||||
setNotaTexto('')
|
||||
setErrorNota(null)
|
||||
} catch (e) {
|
||||
setErrorNota(e instanceof Error ? e.message : 'Error al guardar la nota')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-16">
|
||||
<div className="flex flex-wrap items-start justify-between gap-6">
|
||||
<div>
|
||||
<Link to="/" className="text-sm text-accent hover:underline">
|
||||
← Volver al dashboard
|
||||
</Link>
|
||||
<h1 className="mt-2 text-3xl font-light text-neutral-900">{plan.clienteNombre}</h1>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
{plan.unidadCodigo} · {plan.proyecto} · Venta {plan.ventaRef || '—'} ·{' '}
|
||||
<span className="font-bold text-neutral-900">{status}</span>
|
||||
{atraso > 0 && <span className="text-red-700"> · {atraso} días de atraso</span>}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-neutral-500">
|
||||
Tel. {plan.clienteTelefono || '—'}
|
||||
{tasaMora > 0 && <span> · Mora {tasaMora}% mensual</span>}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => descargar(plan)}
|
||||
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"
|
||||
>
|
||||
Estado de cuenta PDF
|
||||
</button>
|
||||
{status === 'ACTIVO' && saldo > 0 && (
|
||||
<button
|
||||
onClick={cancelar}
|
||||
className="border border-red-300 bg-white px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-red-700 transition-colors duration-150 hover:bg-red-700 hover:text-white"
|
||||
>
|
||||
Cancelar plan
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="border border-red-300 bg-white px-4 py-3 text-sm text-red-700">{error}</div>
|
||||
)}
|
||||
|
||||
{/* KPIs del plan */}
|
||||
<div className="grid grid-cols-2 gap-6 lg:grid-cols-5">
|
||||
<KpiCard titulo="Enganche total" valor={formatoMoneda(plan.engancheTotal)} />
|
||||
<KpiCard titulo="Pagado" valor={formatoMoneda(pagadoTotal(plan))} />
|
||||
<KpiCard titulo="Saldo" valor={formatoMoneda(saldo)} />
|
||||
<KpiCard titulo="% avance" valor={formatoPorcentaje(avancePlan(plan))} />
|
||||
<KpiCard titulo="Mensualidad" valor={formatoMoneda(plan.mensualidad)} detalle={`${plan.numPagos} pagos`} />
|
||||
{mora > 0 && (
|
||||
<KpiCard
|
||||
titulo="Mora acumulada"
|
||||
valor={<span className="text-red-700">{formatoMoneda(mora)}</span>}
|
||||
detalle={`Tasa ${tasaMora}% mensual`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabla de pagos */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-neutral-900 text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
<th scope="col" className="py-3 pr-4">#</th>
|
||||
<th scope="col" className="py-3 pr-4">Vencimiento</th>
|
||||
<th scope="col" className="py-3 pr-4 text-right">Monto</th>
|
||||
<th scope="col" className="py-3 pr-4 text-right">Pagado</th>
|
||||
<th scope="col" className="py-3 pr-4 text-right">Restante</th>
|
||||
{tasaMora > 0 && <th scope="col" className="py-3 pr-4 text-right">Mora</th>}
|
||||
<th scope="col" className="py-3 pr-4">Status</th>
|
||||
<th scope="col" className="py-3 pr-4">Abonos</th>
|
||||
<th scope="col" className="py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{plan.pagos.map((pago) => {
|
||||
const st = statusPago(pago, hoy)
|
||||
const restante = round2(pago.monto - pago.montoPagado)
|
||||
const moraPago = tasaMora > 0 ? moraDePago(pago, tasaMora, hoy) : 0
|
||||
const expandido = expandidos.has(pago.id)
|
||||
return (
|
||||
<Fragment key={pago.id}>
|
||||
<tr className="border-b border-neutral-200 transition-colors duration-150 hover:bg-neutral-50">
|
||||
<td className="py-3 pr-4 font-medium">{pago.numPago}</td>
|
||||
<td className="py-3 pr-4">{formatoFechaCorta(pago.fechaVencimiento)}</td>
|
||||
<td className="py-3 pr-4 text-right">{formatoMoneda(pago.monto)}</td>
|
||||
<td className="py-3 pr-4 text-right">{formatoMoneda(pago.montoPagado)}</td>
|
||||
<td className="py-3 pr-4 text-right font-medium">{formatoMoneda(restante)}</td>
|
||||
{tasaMora > 0 && (
|
||||
<td className="py-3 pr-4 text-right">
|
||||
{moraPago > 0 ? (
|
||||
<span className="text-red-700">{formatoMoneda(moraPago)}</span>
|
||||
) : (
|
||||
<span className="text-neutral-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td className="py-3 pr-4">
|
||||
<BadgeStatus status={st} />
|
||||
</td>
|
||||
<td className="py-3 pr-4">
|
||||
{pago.aplicaciones.length > 0 ? (
|
||||
<button
|
||||
onClick={() => toggleExpandido(pago.id)}
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
{expandido ? 'Ocultar' : `Ver (${pago.aplicaciones.length})`}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-neutral-400">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-3 text-right">
|
||||
{st !== 'PAGADO' && status === 'ACTIVO' && (
|
||||
<button
|
||||
onClick={() => setPagoAbono(pago)}
|
||||
className="bg-accent px-4 py-2 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:opacity-90"
|
||||
>
|
||||
Registrar abono
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{expandido && (
|
||||
<tr className="border-b border-neutral-200 bg-neutral-50">
|
||||
<td colSpan={tasaMora > 0 ? 9 : 8} className="px-4 py-4">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="text-left font-bold uppercase tracking-widest text-neutral-500">
|
||||
<th scope="col" className="py-1">Fecha</th>
|
||||
<th scope="col" className="py-1 text-right">Monto</th>
|
||||
<th scope="col" className="py-1">Referencia</th>
|
||||
<th scope="col" className="py-1 text-right">Recibo</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pago.aplicaciones.map((a) => (
|
||||
<tr key={a.id} className="border-t border-neutral-200">
|
||||
<td className="py-1">{formatoFechaCorta(a.fechaPago)}</td>
|
||||
<td className="py-1 text-right">{formatoMoneda(a.monto)}</td>
|
||||
<td className="py-1">{a.referencia}</td>
|
||||
<td className="py-1 text-right">
|
||||
<button
|
||||
onClick={() => descargarRecibo(plan, pago, a)}
|
||||
aria-label={`Descargar recibo PDF del abono ${a.referencia}`}
|
||||
className="text-accent hover:underline"
|
||||
>
|
||||
Recibo PDF
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Bitácora del plan */}
|
||||
<section className="border border-neutral-200 bg-white p-8" aria-labelledby="titulo-bitacora">
|
||||
<h2 id="titulo-bitacora" className="text-xs font-bold uppercase tracking-widest text-neutral-500">
|
||||
Bitácora
|
||||
</h2>
|
||||
{(plan.notas ?? []).length === 0 ? (
|
||||
<p className="mt-6 text-sm text-neutral-500">Sin notas registradas.</p>
|
||||
) : (
|
||||
<ul className="mt-6 space-y-4">
|
||||
{[...(plan.notas ?? [])]
|
||||
.sort((a, b) => b.fecha.localeCompare(a.fecha))
|
||||
.map((nota) => (
|
||||
<li key={nota.id} className="border-l-2 border-neutral-200 pl-4">
|
||||
<p className="text-xs text-neutral-500">
|
||||
{new Date(nota.fecha).toLocaleString('es-MX', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})}
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-neutral-900">{nota.texto}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<form
|
||||
className="mt-8"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
guardarNota()
|
||||
}}
|
||||
>
|
||||
<label
|
||||
htmlFor="nota-texto"
|
||||
className="block text-xs font-bold uppercase tracking-widest text-neutral-500"
|
||||
>
|
||||
Nueva nota
|
||||
</label>
|
||||
<textarea
|
||||
id="nota-texto"
|
||||
value={notaTexto}
|
||||
onChange={(e) => setNotaTexto(e.target.value)}
|
||||
rows={3}
|
||||
placeholder="Gestión realizada, acuerdos con el cliente…"
|
||||
className="mt-1 w-full border border-neutral-300 bg-white px-3 py-2.5 text-sm transition-colors duration-150 focus:border-neutral-900 focus:outline-none"
|
||||
/>
|
||||
{errorNota && <p className="mt-1 text-xs text-red-700">{errorNota}</p>}
|
||||
<div className="mt-4 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={notaTexto.trim().length < 3}
|
||||
className="bg-accent px-5 py-2.5 text-xs font-bold uppercase tracking-widest text-white transition-colors duration-150 hover:opacity-90 disabled:opacity-40"
|
||||
>
|
||||
Agregar nota
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{status === 'CANCELADO' && (
|
||||
<button
|
||||
onClick={() => void navigate('/')}
|
||||
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"
|
||||
>
|
||||
Volver
|
||||
</button>
|
||||
)}
|
||||
|
||||
{pagoAbono && (
|
||||
<ModalAbono pago={pagoAbono} onCancelar={() => setPagoAbono(null)} onGuardar={guardarAbono} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
11
apps/cobranza/src/services/api.ts
Normal file
11
apps/cobranza/src/services/api.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import type { PlanPago } from '../types'
|
||||
import { cargarPlanes } from './repository'
|
||||
|
||||
/**
|
||||
* Devuelve los planes de pago registrados.
|
||||
* TODO: reemplazar por fetch('/api/planes') cuando el API Gateway esté disponible
|
||||
* TODO: cron de vencidos y envío WhatsApp van en el API Gateway (fase posterior)
|
||||
*/
|
||||
export async function getPlanes(): Promise<PlanPago[]> {
|
||||
return cargarPlanes()
|
||||
}
|
||||
116
apps/cobranza/src/services/repository.test.ts
Normal file
116
apps/cobranza/src/services/repository.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
CLAVE_STORAGE,
|
||||
actualizarPlan,
|
||||
agregarNota,
|
||||
cargarPlanes,
|
||||
cancelarPlan,
|
||||
crearPlan,
|
||||
guardarPlanes,
|
||||
obtenerPlan,
|
||||
registrarAbonoEnPlan,
|
||||
} from './repository'
|
||||
import type { PlanPago } from '../types'
|
||||
|
||||
/** Stub sencillo de localStorage para correr en entorno node */
|
||||
function crearStorageStub(): Pick<Storage, 'getItem' | 'setItem'> {
|
||||
const mapa = new Map<string, string>()
|
||||
return {
|
||||
getItem: (k: string) => mapa.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => {
|
||||
mapa.set(k, v)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('repository (localStorage)', () => {
|
||||
it('siembra datos de ejemplo cuando el storage está vacío', () => {
|
||||
const storage = crearStorageStub()
|
||||
const planes = cargarPlanes(storage)
|
||||
expect(planes.length).toBe(4)
|
||||
expect(storage.getItem(CLAVE_STORAGE)).not.toBeNull()
|
||||
// segunda carga lee del storage, no vuelve a sembrar
|
||||
expect(cargarPlanes(storage)).toEqual(planes)
|
||||
})
|
||||
|
||||
it('round-trip: guardarPlanes → cargarPlanes devuelve lo mismo', () => {
|
||||
const storage = crearStorageStub()
|
||||
const planes = cargarPlanes(storage)
|
||||
const modificados = planes.map((p) => ({ ...p, clienteNombre: `${p.clienteNombre} X` }))
|
||||
guardarPlanes(modificados, storage)
|
||||
expect(cargarPlanes(storage)).toEqual(modificados)
|
||||
})
|
||||
|
||||
it('crearPlan persiste y obtiene el plan por id', () => {
|
||||
const storage = crearStorageStub()
|
||||
cargarPlanes(storage)
|
||||
const plan = crearPlan(
|
||||
{
|
||||
clienteNombre: 'Nuevo Cliente',
|
||||
unidadCodigo: 'U-999',
|
||||
proyecto: 'DIAGONAL',
|
||||
ventaRef: '',
|
||||
fechaInicio: '2026-09-01',
|
||||
engancheTotal: 60000,
|
||||
numPagos: 6,
|
||||
},
|
||||
storage,
|
||||
)
|
||||
expect(plan.mensualidad).toBe(10000)
|
||||
expect(plan.pagos).toHaveLength(6)
|
||||
expect(obtenerPlan(plan.id, storage)).toEqual(plan)
|
||||
expect(cargarPlanes(storage)).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('registrarAbonoEnPlan aplica el abono y deriva LIQUIDADO al cubrir todo', () => {
|
||||
const storage = crearStorageStub()
|
||||
cargarPlanes(storage)
|
||||
let plan = crearPlan(
|
||||
{
|
||||
clienteNombre: 'Cliente Liquida',
|
||||
unidadCodigo: 'U-500',
|
||||
proyecto: 'SENDA',
|
||||
ventaRef: '',
|
||||
fechaInicio: '2026-09-01',
|
||||
engancheTotal: 20000,
|
||||
numPagos: 2,
|
||||
},
|
||||
storage,
|
||||
)
|
||||
plan = registrarAbonoEnPlan(plan.id, { fechaPago: '2026-09-01', monto: 10000, referencia: 'A1' }, plan.pagos[0].id, storage)
|
||||
expect(plan.status).toBe('ACTIVO')
|
||||
plan = registrarAbonoEnPlan(plan.id, { fechaPago: '2026-10-01', monto: 10000, referencia: 'A2' }, plan.pagos[1].id, storage)
|
||||
expect(plan.status).toBe('LIQUIDADO')
|
||||
expect(obtenerPlan(plan.id, storage)?.status).toBe('LIQUIDADO')
|
||||
})
|
||||
|
||||
it('actualizarPlan y cancelarPlan persisten los cambios', () => {
|
||||
const storage = crearStorageStub()
|
||||
const [primero] = cargarPlanes(storage)
|
||||
actualizarPlan({ ...primero, ventaRef: 'VTA-EDIT' }, storage)
|
||||
expect(obtenerPlan(primero.id, storage)?.ventaRef).toBe('VTA-EDIT')
|
||||
cancelarPlan(primero.id, storage)
|
||||
expect(obtenerPlan(primero.id, storage)?.status).toBe('CANCELADO')
|
||||
})
|
||||
|
||||
it('migra planes viejos sin notas agregando notas: []', () => {
|
||||
const storage = crearStorageStub()
|
||||
const [primero] = cargarPlanes(storage)
|
||||
// Simula un registro guardado con una versión anterior del modelo
|
||||
const viejo = { ...primero } as Partial<PlanPago>
|
||||
delete viejo.notas
|
||||
storage.setItem(CLAVE_STORAGE, JSON.stringify([viejo]))
|
||||
const [migrado] = cargarPlanes(storage)
|
||||
expect(migrado.notas).toEqual([])
|
||||
})
|
||||
|
||||
it('agregarNota persiste la nota en la bitácora del plan', () => {
|
||||
const storage = crearStorageStub()
|
||||
const [primero] = cargarPlanes(storage)
|
||||
const actualizado = agregarNota(primero.id, 'Se habló con el cliente', storage)
|
||||
expect(actualizado.notas).toHaveLength(1)
|
||||
expect(actualizado.notas[0].texto).toBe('Se habló con el cliente')
|
||||
expect(obtenerPlan(primero.id, storage)?.notas).toHaveLength(1)
|
||||
expect(() => agregarNota(primero.id, 'ok', storage)).toThrow(/al menos 3/)
|
||||
})
|
||||
})
|
||||
115
apps/cobranza/src/services/repository.ts
Normal file
115
apps/cobranza/src/services/repository.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import type { PlanPago } from '../types'
|
||||
import { generarSeed } from '../data/seed'
|
||||
import { generarCalendario, registrarAbono, statusPlan, agregarNotaAPlan, type AbonoInput } from '../lib/cobranza'
|
||||
import { uuid } from '../lib/uuid'
|
||||
|
||||
export const CLAVE_STORAGE = 'frasa_cobranza_v1'
|
||||
|
||||
// Inyectable para pruebas (stub en vitest, donde no hay localStorage)
|
||||
type StorageLike = Pick<Storage, 'getItem' | 'setItem'>
|
||||
|
||||
function storageDefault(): StorageLike {
|
||||
return globalThis.localStorage
|
||||
}
|
||||
|
||||
/**
|
||||
* Carga los planes desde localStorage; si está vacío siembra los datos de ejemplo.
|
||||
* Migra planes guardados con versiones anteriores del modelo (sin `notas`).
|
||||
* TODO: reemplazar por GET /api/planes cuando el API Gateway esté disponible.
|
||||
*/
|
||||
export function cargarPlanes(storage: StorageLike = storageDefault()): PlanPago[] {
|
||||
const crudo = storage.getItem(CLAVE_STORAGE)
|
||||
if (crudo) {
|
||||
const planes = JSON.parse(crudo) as PlanPago[]
|
||||
return planes.map((p) => (p.notas ? p : { ...p, notas: [] }))
|
||||
}
|
||||
const planes = generarSeed()
|
||||
guardarPlanes(planes, storage)
|
||||
return planes
|
||||
}
|
||||
|
||||
export function guardarPlanes(planes: PlanPago[], storage: StorageLike = storageDefault()): void {
|
||||
storage.setItem(CLAVE_STORAGE, JSON.stringify(planes))
|
||||
}
|
||||
|
||||
export function obtenerPlan(id: string, storage: StorageLike = storageDefault()): PlanPago | undefined {
|
||||
return cargarPlanes(storage).find((p) => p.id === id)
|
||||
}
|
||||
|
||||
export interface NuevoPlanInput {
|
||||
clienteNombre: string
|
||||
clienteTelefono?: string
|
||||
unidadCodigo: string
|
||||
proyecto: string
|
||||
ventaRef: string
|
||||
fechaInicio: string
|
||||
engancheTotal: number
|
||||
numPagos: number
|
||||
tasaMoraMensual?: number
|
||||
}
|
||||
|
||||
export function crearPlan(input: NuevoPlanInput, storage: StorageLike = storageDefault()): PlanPago {
|
||||
const { mensualidad, pagos } = generarCalendario(input.fechaInicio, input.engancheTotal, input.numPagos)
|
||||
const plan: PlanPago = {
|
||||
id: uuid(),
|
||||
ventaRef: input.ventaRef,
|
||||
clienteNombre: input.clienteNombre,
|
||||
clienteTelefono: input.clienteTelefono || undefined,
|
||||
unidadCodigo: input.unidadCodigo,
|
||||
proyecto: input.proyecto,
|
||||
fechaInicio: input.fechaInicio,
|
||||
engancheTotal: input.engancheTotal,
|
||||
numPagos: input.numPagos,
|
||||
mensualidad,
|
||||
tasaMoraMensual: input.tasaMoraMensual,
|
||||
status: 'ACTIVO',
|
||||
pagos,
|
||||
notas: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
guardarPlanes([...cargarPlanes(storage), plan], storage)
|
||||
return plan
|
||||
}
|
||||
|
||||
export function actualizarPlan(plan: PlanPago, storage: StorageLike = storageDefault()): PlanPago {
|
||||
// El status operativo (ACTIVO/LIQUIDADO) se deriva del saldo; CANCELADO es manual
|
||||
const actualizado: PlanPago = { ...plan, status: statusPlan(plan) }
|
||||
guardarPlanes(
|
||||
cargarPlanes(storage).map((p) => (p.id === actualizado.id ? actualizado : p)),
|
||||
storage,
|
||||
)
|
||||
return actualizado
|
||||
}
|
||||
|
||||
export function cancelarPlan(id: string, storage: StorageLike = storageDefault()): PlanPago {
|
||||
const plan = obtenerPlan(id, storage)
|
||||
if (!plan) throw new Error('Plan no encontrado')
|
||||
const cancelado: PlanPago = { ...plan, status: 'CANCELADO' }
|
||||
guardarPlanes(
|
||||
cargarPlanes(storage).map((p) => (p.id === id ? cancelado : p)),
|
||||
storage,
|
||||
)
|
||||
return cancelado
|
||||
}
|
||||
|
||||
export function registrarAbonoEnPlan(
|
||||
planId: string,
|
||||
abono: Omit<AbonoInput, 'pagoId'>,
|
||||
pagoId: string,
|
||||
storage: StorageLike = storageDefault(),
|
||||
): PlanPago {
|
||||
const plan = obtenerPlan(planId, storage)
|
||||
if (!plan) throw new Error('Plan no encontrado')
|
||||
return actualizarPlan(registrarAbono(plan, { ...abono, pagoId }), storage)
|
||||
}
|
||||
|
||||
/** Agrega una nota a la bitácora del plan y persiste el cambio */
|
||||
export function agregarNota(
|
||||
planId: string,
|
||||
texto: string,
|
||||
storage: StorageLike = storageDefault(),
|
||||
): PlanPago {
|
||||
const plan = obtenerPlan(planId, storage)
|
||||
if (!plan) throw new Error('Plan no encontrado')
|
||||
return actualizarPlan(agregarNotaAPlan(plan, texto), storage)
|
||||
}
|
||||
42
apps/cobranza/src/types/index.ts
Normal file
42
apps/cobranza/src/types/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export type StatusPlan = 'ACTIVO' | 'LIQUIDADO' | 'CANCELADO'
|
||||
export type StatusPago = 'PENDIENTE' | 'PAGADO' | 'VENCIDO' | 'PARCIAL'
|
||||
|
||||
export interface PlanPago {
|
||||
id: string // uuid
|
||||
ventaRef: string // referencia de la venta (en el futuro: mondayItemId)
|
||||
clienteNombre: string
|
||||
clienteTelefono?: string // teléfono de contacto (opcional)
|
||||
unidadCodigo: string // p. ej. 'U-101'
|
||||
proyecto: string // p. ej. 'SENDA'
|
||||
fechaInicio: string // ISO YYYY-MM-DD (primer vencimiento)
|
||||
engancheTotal: number // MXN
|
||||
numPagos: number
|
||||
mensualidad: number // MXN (calculada)
|
||||
tasaMoraMensual?: number // % mensual sobre el restante vencido; 0 o ausente = sin mora
|
||||
status: StatusPlan
|
||||
pagos: Pago[]
|
||||
notas: NotaPlan[] // bitácora del plan (cronológica)
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface NotaPlan {
|
||||
id: string
|
||||
fecha: string // ISO con hora
|
||||
texto: string
|
||||
}
|
||||
|
||||
export interface Pago {
|
||||
id: string
|
||||
numPago: number // 1..N
|
||||
fechaVencimiento: string // ISO
|
||||
monto: number // mensualidad programada
|
||||
montoPagado: number // acumulado aplicado
|
||||
aplicaciones: AplicacionPago[] // historial de abonos
|
||||
}
|
||||
|
||||
export interface AplicacionPago {
|
||||
id: string
|
||||
fechaPago: string // ISO
|
||||
monto: number
|
||||
referencia: string // referencia bancaria / folio
|
||||
}
|
||||
22
apps/cobranza/tsconfig.app.json
Normal file
22
apps/cobranza/tsconfig.app.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
apps/cobranza/tsconfig.json
Normal file
7
apps/cobranza/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
19
apps/cobranza/tsconfig.node.json
Normal file
19
apps/cobranza/tsconfig.node.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts", "vitest.config.ts"]
|
||||
}
|
||||
9
apps/cobranza/vite.config.ts
Normal file
9
apps/cobranza/vite.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// La app se sirve detrás de NGINX bajo el prefijo /pay
|
||||
export default defineConfig({
|
||||
base: '/pay/',
|
||||
plugins: [react(), tailwindcss()],
|
||||
})
|
||||
8
apps/cobranza/vitest.config.ts
Normal file
8
apps/cobranza/vitest.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
// Config de tests separada para no mezclar los tipos de Vite y Vitest
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
},
|
||||
})
|
||||
9
apps/cotizador/.dockerignore
Normal file
9
apps/cotizador/.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
*.log
|
||||
coverage
|
||||
.vscode
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user