feat: ecosistema FRASA — apps Cotizador, Alta Clientes, Cobranza y Landing

- Cotizador (/quote): 4 propuestas con formulas validadas del Excel, comparador,
  combobox de unidades, vigencia, PDF ANEXO A
- Alta Clientes (/client): wizard 8 pasos, validacion CURP RENAPO, OCR de INE,
  checklist de expediente, firma digital, 5 PDFs legales
- Cobranza (/pay): planes de enganche, mora, vista cobranza del dia, recibos PDF,
  bitacora, dashboard con filtros
- Landing (/): entrada al ecosistema
- Design system StudioBlank (monocromo, acento #1B4332) en las 4 apps
- 123 tests vitest
This commit is contained in:
2026-08-21 22:27:36 +00:00
commit ecd4edac02
147 changed files with 27345 additions and 0 deletions

View File

@@ -0,0 +1,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>
)
}