- 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).
237 lines
9.7 KiB
TypeScript
237 lines
9.7 KiB
TypeScript
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>
|
||
)
|
||
}
|