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>
|
||||
)
|
||||
}
|
||||
184
apps/cotizador/src/lib/opciones-credito.test.ts
Normal file
184
apps/cotizador/src/lib/opciones-credito.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
AJUSTES_DEFAULT,
|
||||
DESCUENTO_POR_TIPO,
|
||||
TIPOS_ORDEN,
|
||||
calcularOpcionCredito,
|
||||
validarAjustes,
|
||||
type EntradaOpcion,
|
||||
type TipoCredito,
|
||||
} from './opciones-credito'
|
||||
|
||||
/** Caso dorado del cliente: casa de $1,000,000, apartado $15,000, N=14 */
|
||||
const CASO = { precioLista: 1000000, apartado: 15000, numMensualidades: 14, escrituracionDiferida: false }
|
||||
|
||||
function opcion(tipo: TipoCredito, extra: Partial<EntradaOpcion> = {}) {
|
||||
return calcularOpcionCredito({ ...CASO, tipo, ajustes: AJUSTES_DEFAULT[tipo], ...extra })
|
||||
}
|
||||
|
||||
/** Anualidad con la misma fórmula del módulo (el test calcula su esperado) */
|
||||
function anualidadEsperada(monto: number, tasaAnual: number, plazoAnos: number): number {
|
||||
const n = plazoAnos * 12
|
||||
const i = tasaAnual / 12
|
||||
return Number(((monto * i) / (1 - Math.pow(1 + i, -n))).toFixed(2))
|
||||
}
|
||||
|
||||
describe('calcularOpcionCredito — Crédito Bancario (caso dorado)', () => {
|
||||
const r = opcion('CREDITO_BANCARIO')
|
||||
|
||||
it('sin descuento: precio final = lista y saldo a financiar $845,000', () => {
|
||||
expect(DESCUENTO_POR_TIPO.CREDITO_BANCARIO).toBe(0)
|
||||
expect(r.precioFinal).toBe(1000000)
|
||||
expect(r.montoDescuento).toBe(0)
|
||||
expect(r.saldoAFinanciar).toBe(845000) // 1,000,000 − 155,000 del esquema
|
||||
expect(r.pagoContado).toBe(0)
|
||||
expect(r.montoCredito).toBe(845000)
|
||||
})
|
||||
|
||||
it('esquema FRASA 2026 anidado: firma única $65,000 y diferible $75,000', () => {
|
||||
expect(r.esquema.pagoUnicoFirma).toBe(65000) // saldo firma 10,000 + escrituración 55,000
|
||||
expect(r.esquema.baseDiferible).toBe(75000)
|
||||
expect(r.esquema.totalPagado).toBeCloseTo(155000, 2)
|
||||
})
|
||||
|
||||
it('mensualidad estimada con la fórmula de anualidad (10.5% a 20 años)', () => {
|
||||
const esperada = anualidadEsperada(845000, 0.105, 20)
|
||||
expect(esperada).toBeGreaterThan(8000)
|
||||
expect(esperada).toBeLessThan(8500) // ~8,436.xx
|
||||
expect(r.mensualidadEstimada).toBe(esperada)
|
||||
expect(r.interesesEstimados).toBeGreaterThan(0)
|
||||
expect(r.interesesEstimados).toBe(
|
||||
Number((r.mensualidadEstimada! * 12 * 20 - r.montoCredito).toFixed(2)),
|
||||
)
|
||||
expect(r.totalEstimado).toBe(Number((r.precioFinal + r.interesesEstimados).toFixed(2)))
|
||||
})
|
||||
|
||||
it('ahorro vs lista 0 y esquema vigente', () => {
|
||||
expect(r.ahorroVsLista).toBe(0)
|
||||
expect(r.esquemaVigente).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calcularOpcionCredito — Contado (2% de descuento)', () => {
|
||||
const r = opcion('CONTADO')
|
||||
|
||||
it('precio final $980,000 y ahorro de $20,000', () => {
|
||||
expect(r.descuento).toBe(0.02)
|
||||
expect(r.montoDescuento).toBe(20000)
|
||||
expect(r.precioFinal).toBe(980000)
|
||||
expect(r.ahorroVsLista).toBe(20000)
|
||||
})
|
||||
|
||||
it('saldo a financiar = round2(980,000 × 0.845) = $828,100', () => {
|
||||
expect(r.saldoAFinanciar).toBe(828100)
|
||||
})
|
||||
|
||||
it('esquema sobre el precio final: firma única $63,400', () => {
|
||||
expect(r.esquema.saldoFirma).toBe(9500) // 24,500 − 15,000 apartado
|
||||
expect(r.esquema.escrituracion).toBe(53900) // 5.5% de 980,000
|
||||
expect(r.esquema.pagoUnicoFirma).toBe(63400) // 9,500 + 53,900
|
||||
})
|
||||
|
||||
it('sin credito: mensualidad null, intereses 0 y total estimado = precio final', () => {
|
||||
expect(r.montoCredito).toBe(0)
|
||||
expect(r.pagoContado).toBe(0)
|
||||
expect(r.mensualidadEstimada).toBeNull()
|
||||
expect(r.interesesEstimados).toBe(0)
|
||||
expect(r.totalEstimado).toBe(980000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calcularOpcionCredito — Semi Contado (1% + 50% de contado)', () => {
|
||||
const r = opcion('SEMI_CONTADO')
|
||||
|
||||
it('precio final $990,000 y saldo a financiar $836,550', () => {
|
||||
expect(r.montoDescuento).toBe(10000)
|
||||
expect(r.precioFinal).toBe(990000)
|
||||
expect(r.saldoAFinanciar).toBe(836550) // round2(990,000 × 0.845)
|
||||
expect(r.esquemaVigente).toBe(true)
|
||||
})
|
||||
|
||||
it('pago de contado y crédito mitad y mitad', () => {
|
||||
expect(r.pctContado).toBe(0.5)
|
||||
expect(r.pagoContado).toBe(418275)
|
||||
expect(r.montoCredito).toBe(418275)
|
||||
const esperada = anualidadEsperada(418275, 0.105, 20)
|
||||
expect(r.mensualidadEstimada).toBe(esperada)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calcularOpcionCredito — Crédito Infonavit-Cofinavit', () => {
|
||||
it('sin descuento, plazo default 25 años y esquema vigente', () => {
|
||||
const r = opcion('CREDITO_INFONAVIT_COFINAVIT')
|
||||
expect(r.precioFinal).toBe(1000000)
|
||||
expect(r.saldoAFinanciar).toBe(845000)
|
||||
expect(r.plazoAnos).toBe(25)
|
||||
expect(r.esquemaVigente).toBe(true)
|
||||
const esperada = anualidadEsperada(845000, 0.105, 25)
|
||||
expect(r.mensualidadEstimada).toBe(esperada)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calcularOpcionCredito — saldo con los 4 tipos', () => {
|
||||
it('con apartado <= 2.5% del precio final, saldo = round2(precioFinal × 0.845)', () => {
|
||||
for (const tipo of TIPOS_ORDEN) {
|
||||
const r = opcion(tipo)
|
||||
expect(r.saldoAFinanciar).toBe(Number((r.precioFinal * 0.845).toFixed(2)))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('validarAjustes', () => {
|
||||
it('rechaza tasas fuera de rango (0%..50%)', () => {
|
||||
expect(validarAjustes('CREDITO_BANCARIO', { tasaAnual: -0.1, plazoAnos: 20 })).toMatch(/tasa/i)
|
||||
expect(validarAjustes('CREDITO_BANCARIO', { tasaAnual: 0.6, plazoAnos: 20 })).toMatch(/tasa/i)
|
||||
})
|
||||
|
||||
it('rechaza plazos inválidos (entero 1..35)', () => {
|
||||
expect(validarAjustes('CREDITO_BANCARIO', { tasaAnual: 0.105, plazoAnos: 0 })).toMatch(/plazo/i)
|
||||
expect(validarAjustes('CREDITO_BANCARIO', { tasaAnual: 0.105, plazoAnos: 3.5 })).toMatch(/plazo/i)
|
||||
expect(validarAjustes('CREDITO_BANCARIO', { tasaAnual: 0.105, plazoAnos: 36 })).toMatch(/plazo/i)
|
||||
})
|
||||
|
||||
it('rechaza pctContado fuera de 0..1 solo para Semi Contado', () => {
|
||||
expect(validarAjustes('SEMI_CONTADO', { tasaAnual: 0.105, plazoAnos: 20, pctContado: 1.2 })).toMatch(/contado/i)
|
||||
expect(validarAjustes('SEMI_CONTADO', { tasaAnual: 0.105, plazoAnos: 20, pctContado: 0.5 })).toBeNull()
|
||||
})
|
||||
|
||||
it('acepta ajustes válidos', () => {
|
||||
expect(validarAjustes('CREDITO_BANCARIO', { tasaAnual: 0.105, plazoAnos: 20 })).toBeNull()
|
||||
expect(validarAjustes('CONTADO', { tasaAnual: 0, plazoAnos: 1 })).toBeNull()
|
||||
})
|
||||
|
||||
it('calcularOpcionCredito lanza Error cuando la validación falla', () => {
|
||||
expect(() =>
|
||||
calcularOpcionCredito({
|
||||
...CASO,
|
||||
tipo: 'SEMI_CONTADO',
|
||||
ajustes: { tasaAnual: 0.105, plazoAnos: 20, pctContado: 1.5 },
|
||||
}),
|
||||
).toThrow(/contado/i)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calcularOpcionCredito — esquema no vigente', () => {
|
||||
it('apartado que excede el 10% del precio final con descuento: enganche negativo', () => {
|
||||
// Contado: precio final 980,000 → firma 2.5% = 24,500 y enganche 7.5% = 73,500
|
||||
// Apartado 200,000: excedente 175,500 > 73,500 → enganche = 73,500 − 175,500 < 0
|
||||
const r = opcion('CONTADO', { apartado: 200000 })
|
||||
expect(r.precioFinal).toBe(980000)
|
||||
expect(r.esquema.excedenteApartado).toBe(175500)
|
||||
expect(r.esquema.enganche).toBeLessThan(0)
|
||||
expect(r.esquemaVigente).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('calcularOpcionCredito — tasa cero', () => {
|
||||
it('mensualidad = monto / n cuando la tasa es 0', () => {
|
||||
const r = opcion('CREDITO_BANCARIO', { ajustes: { tasaAnual: 0, plazoAnos: 20 } })
|
||||
const mensualidad = Number((845000 / 240).toFixed(2))
|
||||
expect(r.mensualidadEstimada).toBe(mensualidad)
|
||||
// Con tasa 0 los intereses son el redondeo de la mensualidad (±centavos)
|
||||
expect(r.interesesEstimados).toBe(Number((mensualidad * 240 - 845000).toFixed(2)))
|
||||
})
|
||||
})
|
||||
178
apps/cotizador/src/lib/opciones-credito.ts
Normal file
178
apps/cotizador/src/lib/opciones-credito.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Opciones de credito sobre el esquema FRASA 2026 (Excel del cliente, 4
|
||||
* propuestas): Crédito Bancario, Crédito Infonavit-Cofinavit, Semi Contado
|
||||
* y Contado. Cada opcion aplica su descuento (0/0/1%/2%) al precio de lista,
|
||||
* recalcula el esquema FRASA 2026 sobre el precio final y estima la
|
||||
* mensualidad del credito hipotecario restante (saldo a financiar = 84.5%
|
||||
* del precio final, una vez saldado el 15.5% del esquema).
|
||||
*
|
||||
* Módulo puro (sin UI, sin API) para probarlo a fondo.
|
||||
*/
|
||||
|
||||
import { calcularEsquemaFrasa, type EsquemaFrasaResultado } from './esquema-frasa'
|
||||
|
||||
export type TipoCredito =
|
||||
| 'CREDITO_BANCARIO'
|
||||
| 'CREDITO_INFONAVIT_COFINAVIT'
|
||||
| 'SEMI_CONTADO'
|
||||
| 'CONTADO'
|
||||
|
||||
export interface AjustesCredito {
|
||||
/** Tasa anual nominal (0.105 = 10.5%) */
|
||||
tasaAnual: number
|
||||
/** Plazo en años (entero, 1..35) */
|
||||
plazoAnos: number
|
||||
/**
|
||||
* Solo SEMI_CONTADO: fraccion (0..1) del saldo a financiar que el cliente
|
||||
* liquida de contado a la firma; el resto va al credito hipotecario.
|
||||
*/
|
||||
pctContado?: number
|
||||
}
|
||||
|
||||
/** Descuentos sobre el precio de lista definidos en el Excel del cliente */
|
||||
export const DESCUENTO_POR_TIPO: Record<TipoCredito, number> = {
|
||||
CREDITO_BANCARIO: 0,
|
||||
CREDITO_INFONAVIT_COFINAVIT: 0,
|
||||
SEMI_CONTADO: 0.01,
|
||||
CONTADO: 0.02,
|
||||
}
|
||||
|
||||
export const AJUSTES_DEFAULT: Record<TipoCredito, AjustesCredito> = {
|
||||
CREDITO_BANCARIO: { tasaAnual: 0.105, plazoAnos: 20 },
|
||||
CREDITO_INFONAVIT_COFINAVIT: { tasaAnual: 0.105, plazoAnos: 25 },
|
||||
SEMI_CONTADO: { tasaAnual: 0.105, plazoAnos: 20, pctContado: 0.5 },
|
||||
// Valores dummy: el contado no usa credito
|
||||
CONTADO: { tasaAnual: 0, plazoAnos: 1 },
|
||||
}
|
||||
|
||||
/** Orden de presentacion de las 4 opciones (el del Excel del cliente) */
|
||||
export const TIPOS_ORDEN: readonly TipoCredito[] = [
|
||||
'CREDITO_BANCARIO',
|
||||
'CREDITO_INFONAVIT_COFINAVIT',
|
||||
'SEMI_CONTADO',
|
||||
'CONTADO',
|
||||
]
|
||||
|
||||
export const TITULO_POR_TIPO: Record<TipoCredito, string> = {
|
||||
CREDITO_BANCARIO: 'Crédito Bancario',
|
||||
CREDITO_INFONAVIT_COFINAVIT: 'Crédito Infonavit-Cofinavit',
|
||||
SEMI_CONTADO: 'Semi Contado',
|
||||
CONTADO: 'Contado',
|
||||
}
|
||||
|
||||
const round2 = (x: number): number => Number(x.toFixed(2))
|
||||
|
||||
export interface EntradaOpcion {
|
||||
/** Precio de lista de la unidad (sin descuento) */
|
||||
precioLista: number
|
||||
apartado: number
|
||||
numMensualidades: number
|
||||
escrituracionDiferida: boolean
|
||||
tipo: TipoCredito
|
||||
ajustes: AjustesCredito
|
||||
}
|
||||
|
||||
export interface ResultadoOpcionCredito {
|
||||
tipo: TipoCredito
|
||||
titulo: string
|
||||
/** Fraccion de descuento sobre el precio de lista (0/0.01/0.02) */
|
||||
descuento: number
|
||||
montoDescuento: number
|
||||
/** precioLista − montoDescuento (base del esquema FRASA 2026) */
|
||||
precioFinal: number
|
||||
/** Esquema FRASA 2026 calculado sobre el precio final (sin redondear) */
|
||||
esquema: EsquemaFrasaResultado
|
||||
/** precioFinal − esquema.totalPagado (lo que queda para financiar) */
|
||||
saldoAFinanciar: number
|
||||
/** SEMI: saldoAFinanciar * pctContado; resto 0 */
|
||||
pagoContado: number
|
||||
/** saldoAFinanciar − pagoContado (0 en CONTADO) */
|
||||
montoCredito: number
|
||||
tasaAnual: number
|
||||
plazoAnos: number
|
||||
pctContado?: number
|
||||
/** Anualidad del credito; null en CONTADO */
|
||||
mensualidadEstimada: number | null
|
||||
/** mensualidad * 12 * plazoAnos − montoCredito (0 en CONTADO) */
|
||||
interesesEstimados: number
|
||||
/** precioFinal + interesesEstimados */
|
||||
totalEstimado: number
|
||||
/** === montoDescuento: lo que se ahorra vs el precio de lista */
|
||||
ahorroVsLista: number
|
||||
/** false si el apartado excede el 10% del precio final (enganche negativo) */
|
||||
esquemaVigente: boolean
|
||||
}
|
||||
|
||||
/** Error de validación de ajustes; null si son válidos */
|
||||
export function validarAjustes(tipo: TipoCredito, a: AjustesCredito): string | null {
|
||||
if (!Number.isFinite(a.tasaAnual) || a.tasaAnual < 0 || a.tasaAnual > 0.5) {
|
||||
return 'La tasa anual debe estar entre 0% y 50%'
|
||||
}
|
||||
if (!Number.isInteger(a.plazoAnos) || a.plazoAnos < 1 || a.plazoAnos > 35) {
|
||||
return 'El plazo debe ser un número entero de 1 a 35 años'
|
||||
}
|
||||
if (tipo === 'SEMI_CONTADO' && a.pctContado !== undefined) {
|
||||
if (!Number.isFinite(a.pctContado) || a.pctContado < 0 || a.pctContado > 1) {
|
||||
return 'El porcentaje de contado debe estar entre 0% y 100%'
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Anualidad (mensualidad) de un crédito: monto * i / (1 − (1+i)^−n) */
|
||||
function mensualidadCredito(monto: number, tasaAnual: number, plazoAnos: number): number {
|
||||
const n = plazoAnos * 12
|
||||
if (tasaAnual === 0) return monto / n
|
||||
const i = tasaAnual / 12
|
||||
return (monto * i) / (1 - Math.pow(1 + i, -n))
|
||||
}
|
||||
|
||||
export function calcularOpcionCredito(e: EntradaOpcion): ResultadoOpcionCredito {
|
||||
const errorAjustes = validarAjustes(e.tipo, e.ajustes)
|
||||
if (errorAjustes) throw new Error(errorAjustes)
|
||||
|
||||
const descuento = DESCUENTO_POR_TIPO[e.tipo]
|
||||
const montoDescuento = round2(e.precioLista * descuento)
|
||||
const precioFinal = e.precioLista - montoDescuento
|
||||
|
||||
const esquema = calcularEsquemaFrasa({
|
||||
precio: precioFinal,
|
||||
apartado: e.apartado,
|
||||
numMensualidades: e.numMensualidades,
|
||||
escrituracionDiferida: e.escrituracionDiferida,
|
||||
})
|
||||
|
||||
const saldoAFinanciar = round2(precioFinal - esquema.totalPagado)
|
||||
const esContado = e.tipo === 'CONTADO'
|
||||
const pctContado = e.tipo === 'SEMI_CONTADO' ? (e.ajustes.pctContado ?? 0) : 0
|
||||
const pagoContado = esContado ? 0 : round2(saldoAFinanciar * pctContado)
|
||||
const montoCredito = esContado ? 0 : round2(saldoAFinanciar - pagoContado)
|
||||
|
||||
const mensualidadEstimada = esContado
|
||||
? null
|
||||
: round2(mensualidadCredito(montoCredito, e.ajustes.tasaAnual, e.ajustes.plazoAnos))
|
||||
const interesesEstimados = esContado
|
||||
? 0
|
||||
: round2(mensualidadEstimada! * 12 * e.ajustes.plazoAnos - montoCredito)
|
||||
const totalEstimado = round2(precioFinal + interesesEstimados)
|
||||
|
||||
return {
|
||||
tipo: e.tipo,
|
||||
titulo: TITULO_POR_TIPO[e.tipo],
|
||||
descuento,
|
||||
montoDescuento,
|
||||
precioFinal,
|
||||
esquema,
|
||||
saldoAFinanciar,
|
||||
pagoContado,
|
||||
montoCredito,
|
||||
tasaAnual: e.ajustes.tasaAnual,
|
||||
plazoAnos: e.ajustes.plazoAnos,
|
||||
...(e.tipo === 'SEMI_CONTADO' ? { pctContado: e.ajustes.pctContado } : {}),
|
||||
mensualidadEstimada,
|
||||
interesesEstimados,
|
||||
totalEstimado,
|
||||
ahorroVsLista: montoDescuento,
|
||||
esquemaVigente: esquema.enganche >= 0,
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { crearDocumentoPdf, textoCuotaMantenimiento } from './pdf'
|
||||
import { calcularEsquemaFrasa } from './esquema-frasa'
|
||||
import { AJUSTES_DEFAULT, calcularOpcionCredito } from './opciones-credito'
|
||||
import { UNIDADES_SEED } from '../data/unidades'
|
||||
import { DESARROLLOS, LISTA_DESARROLLOS } from '../data/desarrollos'
|
||||
import { formatoMoneda } from './formatters'
|
||||
@@ -71,6 +72,62 @@ describe('crearDocumentoPdf', () => {
|
||||
expect(doc.getNumberOfPages()).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('imprime la sección de financiamiento elegido cuando viene la opción', () => {
|
||||
const unidad = UNIDADES_SEED[0]
|
||||
const opcion = calcularOpcionCredito({
|
||||
precioLista: 1000000,
|
||||
apartado: 15000,
|
||||
numMensualidades: 14,
|
||||
escrituracionDiferida: false,
|
||||
tipo: 'CONTADO',
|
||||
ajustes: AJUSTES_DEFAULT.CONTADO,
|
||||
})
|
||||
const { doc } = crearDocumentoPdf({
|
||||
unidad,
|
||||
acabados: true,
|
||||
apartado: 15000,
|
||||
precioLista: 1000000,
|
||||
esquema: esquemaDorado,
|
||||
opcion,
|
||||
cliente: { nombre: 'Juan Pérez' },
|
||||
})
|
||||
const pdf = new TextDecoder('latin1').decode(doc.output('arraybuffer'))
|
||||
expect(pdf).toContain('PROPUESTA DE FINANCIAMIENTO ELEGIDA')
|
||||
expect(pdf).toContain('CONTADO')
|
||||
// Precio final con descuento, pago de contado y ahorro vs lista
|
||||
expect(pdf).toContain(formatoMoneda(opcion.precioFinal))
|
||||
expect(pdf).toContain(formatoMoneda(opcion.saldoAFinanciar))
|
||||
expect(pdf).toContain(formatoMoneda(opcion.ahorroVsLista))
|
||||
expect(pdf).toContain('NO CONSTITUYE PREAPROBACIÓN')
|
||||
})
|
||||
|
||||
it('imprime tasa y plazo cuando la opción elegida usa crédito', () => {
|
||||
const unidad = UNIDADES_SEED[0]
|
||||
const opcion = calcularOpcionCredito({
|
||||
precioLista: 1000000,
|
||||
apartado: 15000,
|
||||
numMensualidades: 14,
|
||||
escrituracionDiferida: false,
|
||||
tipo: 'CREDITO_BANCARIO',
|
||||
ajustes: AJUSTES_DEFAULT.CREDITO_BANCARIO,
|
||||
})
|
||||
const { doc } = crearDocumentoPdf({
|
||||
unidad,
|
||||
acabados: true,
|
||||
apartado: 15000,
|
||||
precioLista: 1000000,
|
||||
esquema: esquemaDorado,
|
||||
opcion,
|
||||
cliente: { nombre: 'Juan Pérez' },
|
||||
})
|
||||
const pdf = new TextDecoder('latin1').decode(doc.output('arraybuffer'))
|
||||
expect(pdf).toContain('CRÉDITO BANCARIO')
|
||||
expect(pdf).toContain('MENSUALIDAD ESTIMADA')
|
||||
expect(pdf).toContain(formatoMoneda(opcion.mensualidadEstimada!))
|
||||
expect(pdf).toContain('INTERESES ESTIMADOS')
|
||||
expect(pdf).toContain('10.5% A 20 AÑOS')
|
||||
})
|
||||
|
||||
it('imprime el folio local CTE-LOCAL-* cuando el servidor no respondió', () => {
|
||||
const unidad = UNIDADES_SEED[0]
|
||||
const { doc } = crearDocumentoPdf({
|
||||
|
||||
@@ -3,6 +3,7 @@ import autoTable from 'jspdf-autotable'
|
||||
import type { Unidad, Cliente, ParametrosDesarrollo } from '../types'
|
||||
import type { EsquemaFrasaResultado } from './esquema-frasa'
|
||||
import { PCT_ENGANCHE, PCT_ENGANCHE_FIRMA, PCT_ESCRITURACION } from './esquema-frasa'
|
||||
import type { ResultadoOpcionCredito } from './opciones-credito'
|
||||
import { formatoMoneda, formatoPorcentaje, formatoFechaLarga, fechaISOHoy } from './formatters'
|
||||
import { DESARROLLOS, DESARROLLO_DEFAULT } from '../data/desarrollos'
|
||||
|
||||
@@ -13,6 +14,8 @@ export interface PdfInput {
|
||||
precioLista: number
|
||||
/** Esquema único FRASA 2026 a imprimir */
|
||||
esquema: EsquemaFrasaResultado
|
||||
/** Opción de financiamiento elegida por el vendedor (sección de financiamiento) */
|
||||
opcion?: ResultadoOpcionCredito
|
||||
cliente: Cliente
|
||||
/** Parámetros del desarrollo; por defecto DIAGONAL */
|
||||
desarrollo?: ParametrosDesarrollo
|
||||
@@ -51,7 +54,7 @@ export interface DocumentoPdf {
|
||||
}
|
||||
|
||||
/** Construye el documento PDF "ANEXO A" (sin descargarlo) */
|
||||
export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, esquema, cliente, desarrollo, folio, qrDataUrl, vigencia }: PdfInput): DocumentoPdf {
|
||||
export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, esquema, opcion, cliente, desarrollo, folio, qrDataUrl, vigencia }: PdfInput): DocumentoPdf {
|
||||
const params = desarrollo ?? DESARROLLOS[DESARROLLO_DEFAULT]
|
||||
const doc = new jsPDF()
|
||||
const hoy = new Date()
|
||||
@@ -180,6 +183,53 @@ export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, esq
|
||||
y = finalY(doc) + 6
|
||||
y += 2
|
||||
|
||||
// 3.1 Propuesta de financiamiento elegida (una de las 4 opciones de crédito)
|
||||
if (opcion) {
|
||||
doc.setFontSize(12)
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.text(`PROPUESTA DE FINANCIAMIENTO ELEGIDA — ${opcion.titulo.toUpperCase()}`, MARGEN, y)
|
||||
y += 4
|
||||
|
||||
const renglones: string[][] = [
|
||||
['PRECIO FINAL C/DESC.', formatoMoneda(opcion.precioFinal)],
|
||||
...(opcion.montoDescuento > 0
|
||||
? [[`DESCUENTO (${formatoPorcentaje(opcion.descuento)})`, formatoMoneda(opcion.montoDescuento)]]
|
||||
: []),
|
||||
...(opcion.tipo === 'CONTADO'
|
||||
? [
|
||||
['PAGO DE CONTADO', formatoMoneda(opcion.saldoAFinanciar)],
|
||||
['AHORRO VS PRECIO DE LISTA', formatoMoneda(opcion.ahorroVsLista)],
|
||||
]
|
||||
: [
|
||||
...(opcion.tipo === 'SEMI_CONTADO'
|
||||
? [
|
||||
['PAGO DE CONTADO A LA FIRMA', formatoMoneda(opcion.pagoContado)],
|
||||
['SALDO RESTANTE A FINANCIAR', formatoMoneda(opcion.montoCredito)],
|
||||
]
|
||||
: [['SALDO A FINANCIAR', formatoMoneda(opcion.saldoAFinanciar)]]),
|
||||
['MONTO DEL CRÉDITO', formatoMoneda(opcion.montoCredito)],
|
||||
['TASA Y PLAZO', `${(opcion.tasaAnual * 100).toLocaleString('es-MX')}% A ${opcion.plazoAnos} AÑOS`],
|
||||
['MENSUALIDAD ESTIMADA', formatoMoneda(opcion.mensualidadEstimada ?? 0)],
|
||||
['INTERESES ESTIMADOS', formatoMoneda(opcion.interesesEstimados)],
|
||||
]),
|
||||
['TOTAL ESTIMADO', formatoMoneda(opcion.totalEstimado)],
|
||||
]
|
||||
|
||||
autoTable(doc, {
|
||||
startY: y,
|
||||
margin: { left: MARGEN, right: MARGEN },
|
||||
styles: { fontSize: 8 },
|
||||
headStyles: { fillColor: [37, 99, 235] },
|
||||
head: [['CONCEPTO', 'TOTAL']],
|
||||
body: renglones,
|
||||
})
|
||||
y = finalY(doc) + 4
|
||||
parrafo(
|
||||
'Mensualidad estimada sujeta a aprobación del banco; no constituye preaprobación.',
|
||||
)
|
||||
y += 2
|
||||
}
|
||||
|
||||
// 4. ¿Qué sigue?
|
||||
parrafo('¿QUÉ SIGUE?', { negritas: true, tamano: 11 })
|
||||
parrafo(`ENTREGAR LOS SIGUIENTES DOCUMENTOS MÁXIMO EL DÍA: ${formatoFechaLarga(limiteDocumentos)}`, {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
@@ -11,6 +11,7 @@ import toast from "react-hot-toast"
|
||||
import { UnitSelector } from '../components/UnitSelector'
|
||||
import { UnitInfoCard } from '../components/UnitInfoCard'
|
||||
import { EsquemaPanel } from '../components/EsquemaPanel'
|
||||
import { OpcionesCredito } from '../components/OpcionesCredito'
|
||||
import { MisCotizaciones } from '../components/MisCotizaciones'
|
||||
import { ModalCliente } from '../components/ModalCliente'
|
||||
import { BuscadorCliente } from '../components/BuscadorCliente'
|
||||
@@ -19,6 +20,13 @@ import { formatoMoneda, formatoFechaLarga } from '../lib/formatters'
|
||||
import { calcularVigencia } from '../lib/vigencia'
|
||||
import { folioCotizacion } from '../lib/folio'
|
||||
import { textoCompartir, urlWhatsApp } from '../lib/compartir'
|
||||
import {
|
||||
AJUSTES_DEFAULT,
|
||||
TIPOS_ORDEN,
|
||||
calcularOpcionCredito,
|
||||
type AjustesCredito,
|
||||
type TipoCredito,
|
||||
} from '../lib/opciones-credito'
|
||||
import { desarrolloEfectivo, idsCotizables } from '../lib/reglas'
|
||||
import {
|
||||
getMisCotizaciones,
|
||||
@@ -161,6 +169,39 @@ export default function CotizadorPage() {
|
||||
|
||||
const nombreCliente = watch('nombre') ?? ''
|
||||
|
||||
// 4 opciones de crédito sobre el esquema FRASA 2026 (saldo 84.5% a financiar)
|
||||
const [ajustesCredito, setAjustesCredito] = useState<Record<TipoCredito, AjustesCredito>>(() =>
|
||||
structuredClone(AJUSTES_DEFAULT),
|
||||
)
|
||||
const [opcionElegida, setOpcionElegida] = useState<TipoCredito>('CREDITO_BANCARIO')
|
||||
|
||||
const opcionesCredito = useMemo(() => {
|
||||
// Sin unidad no hay opciones que calcular (la sección no se renderiza)
|
||||
if (!Number.isFinite(precioLista) || precioLista <= 0) return []
|
||||
const calcular = (ajustes: Record<TipoCredito, AjustesCredito>) =>
|
||||
TIPOS_ORDEN.map((tipo) =>
|
||||
calcularOpcionCredito({
|
||||
precioLista,
|
||||
apartado: apartadoEfectivo,
|
||||
numMensualidades,
|
||||
escrituracionDiferida,
|
||||
tipo,
|
||||
ajustes: ajustes[tipo],
|
||||
}),
|
||||
)
|
||||
try {
|
||||
return calcular(ajustesCredito)
|
||||
} catch {
|
||||
// Ajustes inválidos (estado restaurado corrupto, carrera de inputs):
|
||||
// se recalcula con los defaults para no romper la página
|
||||
try {
|
||||
return calcular(AJUSTES_DEFAULT)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
}, [precioLista, apartadoEfectivo, numMensualidades, escrituracionDiferida, ajustesCredito])
|
||||
|
||||
// Prefill desde la URL (botón "Cotizar" de Mi día): cliente, contacto,
|
||||
// desarrollo, unidad y el id de la oportunidad a vincular al guardar
|
||||
useEffect(() => {
|
||||
@@ -265,6 +306,7 @@ export default function CotizadorPage() {
|
||||
numMensualidades,
|
||||
mensualidad: resultado.mensualidad,
|
||||
pagoFinal: resultado.pagoUnicoFirma,
|
||||
opcionesCredito: JSON.stringify({ elegida: opcionElegida, ajustes: ajustesCredito }),
|
||||
createdAt: new Date(timestamp).toISOString(),
|
||||
}
|
||||
}
|
||||
@@ -288,6 +330,7 @@ export default function CotizadorPage() {
|
||||
apartado: apartadoEfectivo,
|
||||
numMensualidades,
|
||||
escrituracionDiferida,
|
||||
opcionesCredito: JSON.stringify({ elegida: opcionElegida, ajustes: ajustesCredito }),
|
||||
...(oportunidadId !== null ? { oportunidadId } : {}),
|
||||
})
|
||||
const conFolio = { ...guardada, folio: guardada.folio ?? folioCotizacion(guardada.id) }
|
||||
@@ -337,6 +380,8 @@ export default function CotizadorPage() {
|
||||
precioLista,
|
||||
// El PDF imprime SOLO el esquema único FRASA 2026
|
||||
esquema: resultado,
|
||||
// + la opción de financiamiento elegida (Crédito Bancario por default)
|
||||
opcion: opcionesCredito.find((o) => o.tipo === opcionElegida),
|
||||
cliente,
|
||||
desarrollo: params,
|
||||
folio,
|
||||
@@ -457,6 +502,39 @@ export default function CotizadorPage() {
|
||||
setValue('telefono', c.clienteTelefono ?? '', { shouldValidate: true })
|
||||
setValue('email', c.clienteEmail ?? '', { shouldValidate: true })
|
||||
setUltimaGuardada(c)
|
||||
// Restaura la opción de crédito elegida y sus ajustes (guardados como JSON)
|
||||
if (typeof c.opcionesCredito === 'string' && c.opcionesCredito.length > 0) {
|
||||
try {
|
||||
const guardado = JSON.parse(c.opcionesCredito) as {
|
||||
elegida?: TipoCredito
|
||||
ajustes?: Record<TipoCredito, AjustesCredito>
|
||||
}
|
||||
if (guardado.elegida && TIPOS_ORDEN.includes(guardado.elegida)) {
|
||||
setOpcionElegida(guardado.elegida)
|
||||
}
|
||||
if (guardado.ajustes && typeof guardado.ajustes === 'object') {
|
||||
setAjustesCredito((prev) => {
|
||||
const siguiente = { ...prev }
|
||||
for (const tipo of TIPOS_ORDEN) {
|
||||
const a = guardado.ajustes?.[tipo]
|
||||
// Solo se restauran ajustes con la forma esperada; si no, el default
|
||||
if (a && typeof a.tasaAnual === 'number' && typeof a.plazoAnos === 'number') {
|
||||
siguiente[tipo] = {
|
||||
tasaAnual: a.tasaAnual,
|
||||
plazoAnos: a.plazoAnos,
|
||||
...(tipo === 'SEMI_CONTADO' && typeof a.pctContado === 'number'
|
||||
? { pctContado: a.pctContado }
|
||||
: {}),
|
||||
}
|
||||
}
|
||||
}
|
||||
return siguiente
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// JSON corrupto: se conservan los ajustes actuales (defaults)
|
||||
}
|
||||
}
|
||||
const avisoEsquema = nueva
|
||||
? ''
|
||||
: ` Usa el esquema anterior (${c.tipoPropuesta}); se cargó con el esquema FRASA 2026 y sus valores por defecto.`
|
||||
@@ -701,13 +779,24 @@ export default function CotizadorPage() {
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<EsquemaPanel
|
||||
esquema={resultado}
|
||||
numMensualidades={numMensualidades}
|
||||
onNumMensualidades={setNumMensualidades}
|
||||
escrituracionDiferida={escrituracionDiferida}
|
||||
onEscrituracionDiferida={setEscrituracionDiferida}
|
||||
/>
|
||||
<>
|
||||
<EsquemaPanel
|
||||
esquema={resultado}
|
||||
numMensualidades={numMensualidades}
|
||||
onNumMensualidades={setNumMensualidades}
|
||||
escrituracionDiferida={escrituracionDiferida}
|
||||
onEscrituracionDiferida={setEscrituracionDiferida}
|
||||
/>
|
||||
<OpcionesCredito
|
||||
opciones={opcionesCredito}
|
||||
ajustes={ajustesCredito}
|
||||
onCambiarAjustes={(tipo, ajustes) =>
|
||||
setAjustesCredito((prev) => ({ ...prev, [tipo]: ajustes }))
|
||||
}
|
||||
elegida={opcionElegida}
|
||||
onElegir={setOpcionElegida}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -107,6 +107,8 @@ export interface CotizacionGuardada {
|
||||
numMensualidades: number
|
||||
mensualidad: number | null
|
||||
pagoFinal: number
|
||||
/** JSON {elegida, ajustes} de las opciones de crédito (filas nuevas) */
|
||||
opcionesCredito?: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
@@ -124,6 +126,8 @@ export interface GuardarCotizacionPayload {
|
||||
escrituracionDiferida?: boolean
|
||||
/** Item del tablero Oportunidades a vincular (botón "Cotizar" de Mi día) */
|
||||
oportunidadId?: string
|
||||
/** JSON serializado {elegida, ajustes} de las 4 opciones de crédito; el API lo guarda crudo */
|
||||
opcionesCredito?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user