feat(cotizador): 4 opciones de credito sobre el esquema FRASA (saldo 84.5% con mensualidad estimada)

- Nuevo modulo puro lib/opciones-credito.ts: Bancario, Infonavit-Cofinavit,
  Semi Contado (1%) y Contado (2%), con descuentos del Excel del cliente.
  Cada opcion recalcula el esquema FRASA 2026 sobre el precio final y
  estima la mensualidad del credito (anualidad con tasa y plazo ajustables;
  % de contado para la Semi). Ajustes validados (tasa 0-50%, plazo 1-35).
- Nuevo componente OpcionesCredito: grid de 4 tarjetas con tasa/plazo/% de
  contado editables, mensualidad estimada y boton 'Usar en el PDF'.
- CotizadorPage: estado de ajustes y opcion elegida, memo con try/catch
  (defaults de respaldo), restauracion defensiva al retomar cotizacion.
- PDF: seccion 'PROPUESTA DE FINANCIAMIENTO ELEGIDA' con renglones del
  credito (o pago de contado y ahorro para Contado) + disclaimer.
- API: columna Cotizacion.opcionesCredito (TEXT, JSON {elegida, ajustes})
  con migracion manual aplicada en SQLite; zod max 4000; se persiste crudo
  y los GET devuelven la fila completa.

Tests: cotizador 149/149 (19 nuevos en opciones-credito, 2 en pdf),
API 746/746 (3 nuevos en cotizaciones). Smoke real POST+GET en produccion
local con unidad ATELIER-34 (201 con opcionesCredito; fila de prueba borrada).
This commit is contained in:
2026-09-28 19:56:37 +00:00
parent 61d1b84fc3
commit 4a3ddd2f35
11 changed files with 863 additions and 9 deletions

View File

@@ -0,0 +1,236 @@
import type { AjustesCredito, ResultadoOpcionCredito, TipoCredito } from '../lib/opciones-credito'
import { formatoMoneda, formatoPorcentaje } from '../lib/formatters'
interface Props {
opciones: ResultadoOpcionCredito[]
ajustes: Record<TipoCredito, AjustesCredito>
onCambiarAjustes: (tipo: TipoCredito, ajustes: AjustesCredito) => void
elegida: TipoCredito
onElegir: (tipo: TipoCredito) => void
}
const cardBase =
'flex flex-col rounded-xl border border-[#E5E7EB] bg-white p-5 shadow-sm dark:border-[#1E293B] dark:bg-[#0F172A]'
const etiqueta = 'mb-1 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
const inputClase =
'w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#E2E8F0]'
const fila = 'flex flex-col gap-1 py-1.5 sm:flex-row sm:items-center sm:justify-between'
const filaEtiqueta = 'text-sm text-[#64748B] dark:text-[#94A3B8]'
const filaMonto = 'text-sm font-medium text-[#111827] dark:text-[#E2E8F0]'
/** Clamp básico de los ajustes al cambiar un input (evita estados inválidos) */
function ajustar(tipo: TipoCredito, previo: AjustesCredito, parcial: Partial<AjustesCredito>): AjustesCredito {
const siguiente = { ...previo, ...parcial }
if (tipo === 'SEMI_CONTADO') {
const pct = Number(siguiente.pctContado ?? 0)
siguiente.pctContado = Math.min(1, Math.max(0, Number.isFinite(pct) ? pct : 0))
}
const tasa = Number(siguiente.tasaAnual)
siguiente.tasaAnual = Math.min(0.5, Math.max(0, Number.isFinite(tasa) ? tasa : 0))
const plazo = Number(siguiente.plazoAnos)
siguiente.plazoAnos = Math.min(35, Math.max(1, Number.isFinite(plazo) ? Math.round(plazo) : 20))
return siguiente
}
function Fila({ titulo, monto, negritas = false }: { titulo: string; monto: string; negritas?: boolean }) {
return (
<div className={fila}>
<dt className={negritas ? `text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]` : filaEtiqueta}>
{titulo}
</dt>
<dd className={negritas ? 'text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]' : filaMonto}>
{monto}
</dd>
</div>
)
}
function TarjetaOpcion({
r,
ajuste,
elegida,
onCambiarAjustes,
onElegir,
}: {
r: ResultadoOpcionCredito
ajuste: AjustesCredito
elegida: boolean
onCambiarAjustes: (tipo: TipoCredito, ajustes: AjustesCredito) => void
onElegir: (tipo: TipoCredito) => void
}) {
const conCredito = r.tipo !== 'CONTADO'
const esSemi = r.tipo === 'SEMI_CONTADO'
return (
<article
className={`${cardBase} ${elegida ? 'ring-2 ring-accent' : ''} ${r.esquemaVigente ? '' : 'opacity-90'}`}
>
<header className="mb-3 flex items-start justify-between gap-2">
<h3 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">{r.titulo}</h3>
{r.descuento > 0 && (
<span className="inline-flex shrink-0 items-center rounded-full bg-accent/10 px-2.5 py-1 text-xs font-semibold text-accent">
Descuento {formatoPorcentaje(r.descuento)}
</span>
)}
</header>
{!r.esquemaVigente ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs font-medium text-amber-800 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-200">
El apartado supera el 10% del precio final con descuento; revisa el monto de apartado.
</p>
) : (
<>
<dl className="divide-y divide-[#E5E7EB] dark:divide-[#1E293B]">
<Fila titulo="Precio final c/desc." monto={formatoMoneda(r.precioFinal)} />
<Fila
titulo="Esquema FRASA 2026"
monto={`A la firma ${formatoMoneda(r.esquema.pagoUnicoFirma)} · ${r.esquema.numMensualidades} × ${formatoMoneda(r.esquema.mensualidad)} diferible`}
/>
{r.tipo === 'CONTADO' ? (
<>
<div className={fila}>
<dt className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
Se liquida de contado
</dt>
<dd className="text-sm font-semibold text-accent">{formatoMoneda(r.saldoAFinanciar)}</dd>
</div>
<Fila titulo="Ahorro vs precio de lista" monto={formatoMoneda(r.ahorroVsLista)} />
<Fila titulo="Total a pagar" monto={formatoMoneda(r.totalEstimado)} negritas />
</>
) : (
<Fila titulo="Saldo a financiar (84.5%)" monto={formatoMoneda(r.saldoAFinanciar)} />
)}
{esSemi && (
<Fila titulo="Pago de contado a la firma" monto={formatoMoneda(r.pagoContado)} />
)}
</dl>
{esSemi && (
<div className="mt-3">
<label htmlFor={`pct-contado-${r.tipo}`} className={etiqueta}>
% de contado
</label>
<input
id={`pct-contado-${r.tipo}`}
type="number"
min={0}
max={100}
step={5}
value={Math.round((ajuste.pctContado ?? 0) * 100)}
onChange={(e) => {
const v = Number(e.target.value)
const pct = Number.isFinite(v) ? Math.min(100, Math.max(0, v)) / 100 : 0
onCambiarAjustes(r.tipo, ajustar(r.tipo, ajuste, { pctContado: pct }))
}}
className={inputClase}
/>
</div>
)}
{conCredito && (
<>
<div className="mt-3 grid grid-cols-2 gap-3">
<div>
<label htmlFor={`tasa-${r.tipo}`} className={etiqueta}>
Tasa anual %
</label>
<input
id={`tasa-${r.tipo}`}
type="number"
min={0}
max={50}
step={0.1}
value={Number((ajuste.tasaAnual * 100).toFixed(2))}
onChange={(e) => {
const v = Number(e.target.value)
onCambiarAjustes(
r.tipo,
ajustar(r.tipo, ajuste, { tasaAnual: Number.isFinite(v) ? v / 100 : 0 }),
)
}}
className={inputClase}
/>
</div>
<div>
<label htmlFor={`plazo-${r.tipo}`} className={etiqueta}>
Plazo
</label>
<select
id={`plazo-${r.tipo}`}
value={ajuste.plazoAnos}
onChange={(e) => onCambiarAjustes(r.tipo, ajustar(r.tipo, ajuste, { plazoAnos: Number(e.target.value) }))}
className={inputClase}
>
{Array.from({ length: 31 }, (_, i) => i + 5).map((n) => (
<option key={n} value={n}>
{n} años
</option>
))}
</select>
</div>
</div>
<div className="mt-3 rounded-lg bg-accent/10 px-3 py-2.5">
<p className="text-xs font-medium text-[#64748B] dark:text-[#94A3B8]">Mensualidad estimada</p>
<p className="text-lg font-semibold text-accent">{formatoMoneda(r.mensualidadEstimada ?? 0)}</p>
</div>
<dl className="mt-3 divide-y divide-[#E5E7EB] dark:divide-[#1E293B]">
<Fila titulo="Monto del crédito" monto={formatoMoneda(r.montoCredito)} />
<Fila titulo="Intereses estimados" monto={formatoMoneda(r.interesesEstimados)} />
<Fila titulo="Total estimado" monto={formatoMoneda(r.totalEstimado)} negritas />
</dl>
<p className="mt-3 text-xs text-[#64748B] dark:text-[#94A3B8]">
Mensualidad estimada con tasa y plazo ajustables; sujeta a aprobación del banco. No
constituye preaprobación.
</p>
</>
)}
</>
)}
<button
type="button"
onClick={() => onElegir(r.tipo)}
aria-pressed={elegida}
className={`mt-4 w-full rounded-lg px-4 py-2 text-sm font-medium shadow-sm transition-all duration-100 ease-out ${
elegida
? 'bg-accent text-white hover:bg-accent-hover'
: 'border border-[#D1D5DB] bg-white text-[#374151] hover:bg-[#F9FAFB] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] dark:hover:bg-[#1E293B]'
}`}
>
{elegida ? 'Elegida — se incluye en el PDF' : 'Usar en el PDF'}
</button>
</article>
)
}
/**
* 4 opciones de financiamiento del saldo (84.5% del precio final) sobre el
* esquema FRASA 2026: Crédito Bancario, Infonavit-Cofinavit, Semi Contado y
* Contado. Cada tarjeta ajusta su tasa/plazo (y % de contado para la semi),
* estima la mensualidad del crédito y permite elegir la propuesta que sale
* en el PDF.
*/
export function OpcionesCredito({ opciones, ajustes, onCambiarAjustes, elegida, onElegir }: Props) {
return (
<section className="mt-6">
<h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#111827] dark:text-[#E2E8F0]">
Opciones de financiamiento del saldo (84.5% del precio final)
</h2>
<div className="grid gap-4 sm:grid-cols-2">
{opciones.map((r) => (
<TarjetaOpcion
key={r.tipo}
r={r}
ajuste={ajustes[r.tipo]}
elegida={r.tipo === elegida}
onCambiarAjustes={onCambiarAjustes}
onElegir={onElegir}
/>
))}
</div>
</section>
)
}