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:
236
apps/cotizador/src/components/OpcionesCredito.tsx
Normal file
236
apps/cotizador/src/components/OpcionesCredito.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user