Files
FRASA-CAS/apps/cotizador/src/components/OpcionesCredito.tsx
Consult AS Technology 4a3ddd2f35 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).
2026-09-28 19:56:37 +00:00

237 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}