feat(cotizador): tope de mensualidades = meses que faltan para la entrega (UI y API); nunca se rebasa la fecha de entrega
This commit is contained in:
@@ -13,6 +13,8 @@ interface Props {
|
||||
esquema: EsquemaFrasaResultado
|
||||
/** Fecha de entrega estimada de la unidad (captura de obra); alimenta la nota del diferimiento */
|
||||
fechaEntrega?: string | null
|
||||
/** Tope de mensualidades: los meses que faltan para la entrega (default 36 sin fecha) */
|
||||
maxMensualidades?: number
|
||||
numMensualidades: number
|
||||
onNumMensualidades: (n: number) => void
|
||||
escrituracionDiferida: boolean
|
||||
@@ -28,17 +30,23 @@ const pasoMonto = 'mt-1 text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]
|
||||
* Panel único del esquema FRASA 2026 (reemplaza a las 4 propuestas):
|
||||
* línea de tiempo Hoy → A la firma → Mensualidades, el detalle de conceptos
|
||||
* (enganche de firma 2.5%, enganche 7.5%, escrituración 5.5%) y los controles
|
||||
* del diferimiento: número de mensualidades (1..36) y si la escrituración se
|
||||
* difiere con el enganche o se paga a la firma.
|
||||
* del diferimiento: número de mensualidades (1..tope, donde el tope son los
|
||||
* meses que faltan para la entrega estimada de la unidad) y si la escrituración
|
||||
* se difiere con el enganche o se paga a la firma.
|
||||
*/
|
||||
export function EsquemaPanel({
|
||||
esquema,
|
||||
fechaEntrega,
|
||||
maxMensualidades = MENSUALIDADES_MAX,
|
||||
numMensualidades,
|
||||
onNumMensualidades,
|
||||
escrituracionDiferida,
|
||||
onEscrituracionDiferida,
|
||||
}: Props) {
|
||||
// El diferimiento no puede rebasar la entrega de la vivienda: se ofrecen
|
||||
// sólo las mensualidades dentro del tope (con fecha, meses que faltan)
|
||||
const tope = Math.max(MENSUALIDADES_MIN, Math.min(MENSUALIDADES_MAX, maxMensualidades))
|
||||
const mensualidadEfectiva = Math.min(numMensualidades, tope)
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Línea de tiempo del esquema */}
|
||||
@@ -135,11 +143,11 @@ export function EsquemaPanel({
|
||||
</label>
|
||||
<select
|
||||
id="mensualidades-esquema"
|
||||
value={numMensualidades}
|
||||
onChange={(e) => onNumMensualidades(Number(e.target.value))}
|
||||
value={mensualidadEfectiva}
|
||||
onChange={(e) => onNumMensualidades(Math.min(Number(e.target.value), tope))}
|
||||
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2.5 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]"
|
||||
>
|
||||
{Array.from({ length: MENSUALIDADES_MAX - MENSUALIDADES_MIN + 1 }, (_, i) => MENSUALIDADES_MIN + i).map(
|
||||
{Array.from({ length: tope - MENSUALIDADES_MIN + 1 }, (_, i) => MENSUALIDADES_MIN + i).map(
|
||||
(n) => (
|
||||
<option key={n} value={n}>
|
||||
{n} mensualidades
|
||||
@@ -149,8 +157,8 @@ export function EsquemaPanel({
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
||||
{fechaEntrega
|
||||
? `Sugeridas por la entrega estimada (${new Date(`${fechaEntrega}T00:00:00`).toLocaleDateString('es-MX', { day: 'numeric', month: 'short', year: 'numeric' })}); cada una de ${formatoMoneda(esquema.mensualidad)}`
|
||||
: `De ${MENSUALIDADES_MIN} a ${MENSUALIDADES_MAX}; cada una de ${formatoMoneda(esquema.mensualidad)}`}
|
||||
? `Tope: la entrega estimada es el ${new Date(`${fechaEntrega}T00:00:00`).toLocaleDateString('es-MX', { day: 'numeric', month: 'short', year: 'numeric' })} (máx. ${tope} mensualidades); cada una de ${formatoMoneda(esquema.mensualidad)}`
|
||||
: `De ${MENSUALIDADES_MIN} a ${tope}; cada una de ${formatoMoneda(esquema.mensualidad)}`}
|
||||
</p>
|
||||
</div>
|
||||
<label className="flex cursor-pointer items-center gap-2 pb-2.5 text-sm text-[#374151] dark:text-[#CBD5E1]">
|
||||
|
||||
@@ -105,13 +105,24 @@ describe('useCotizacion — esquema único FRASA 2026', () => {
|
||||
|
||||
it('las mensualidades elegidas recalculan la mensualidad sobre el diferible', () => {
|
||||
const { result } = montar()
|
||||
const n = Math.min(6, mensualidadesParaUnidad(unidadDiagonal))
|
||||
|
||||
act(() => {
|
||||
result.current.setNumMensualidades(12)
|
||||
result.current.setNumMensualidades(n)
|
||||
})
|
||||
const r = result.current.resultado!
|
||||
expect(r.numMensualidades).toBe(12)
|
||||
expect(r.mensualidad).toBeCloseTo(r.baseDiferible / 12, 6)
|
||||
expect(r.numMensualidades).toBe(n)
|
||||
expect(r.mensualidad).toBeCloseTo(r.baseDiferible / n, 6)
|
||||
})
|
||||
|
||||
it('el ajuste de mensualidades respeta el tope de la entrega estimada', () => {
|
||||
const { result } = montar()
|
||||
const tope = mensualidadesParaUnidad(unidadDiagonal)
|
||||
|
||||
act(() => {
|
||||
result.current.setNumMensualidades(tope + 10)
|
||||
})
|
||||
expect(result.current.numMensualidades).toBe(tope)
|
||||
})
|
||||
|
||||
it('escrituración diferida: sube el diferible y baja el pago a la firma', () => {
|
||||
@@ -126,8 +137,9 @@ describe('useCotizacion — esquema único FRASA 2026', () => {
|
||||
expect(r.pagoUnicoFirma).toBeCloseTo(r.saldoFirma, 6)
|
||||
})
|
||||
|
||||
it('restaurar carga una cotización guardada del esquema (N y escrituración)', () => {
|
||||
it('restaurar carga una cotización guardada (N acotado a la entrega y escrituración)', () => {
|
||||
const { result } = montar()
|
||||
const tope = mensualidadesParaUnidad(unidadDiagonal)
|
||||
|
||||
act(() => {
|
||||
result.current.restaurar({
|
||||
@@ -141,12 +153,13 @@ describe('useCotizacion — esquema único FRASA 2026', () => {
|
||||
})
|
||||
expect(result.current.acabados).toBe(true)
|
||||
expect(result.current.apartadoEfectivo).toBe(20000)
|
||||
expect(result.current.numMensualidades).toBe(24)
|
||||
// El diferimiento no puede rebasar la entrega: 24 se acota a las que faltan
|
||||
expect(result.current.numMensualidades).toBe(tope)
|
||||
expect(result.current.escrituracionDiferida).toBe(true)
|
||||
expect(result.current.resultado?.numMensualidades).toBe(24)
|
||||
expect(result.current.resultado?.numMensualidades).toBe(tope)
|
||||
})
|
||||
|
||||
it('restaurar una cotización legacy (sin N ni escrituración) cae a los defaults', () => {
|
||||
it('restaurar una cotización legacy (sin N ni escrituración): default acotado a la entrega', () => {
|
||||
const { result } = montar()
|
||||
|
||||
act(() => {
|
||||
@@ -157,7 +170,10 @@ describe('useCotizacion — esquema único FRASA 2026', () => {
|
||||
apartado: 20000,
|
||||
})
|
||||
})
|
||||
expect(result.current.numMensualidades).toBe(MENSUALIDADES_DEFAULT)
|
||||
// Sin N guardado cae al default (14), pero nunca rebasando la entrega
|
||||
expect(result.current.numMensualidades).toBe(
|
||||
Math.min(MENSUALIDADES_DEFAULT, mensualidadesParaUnidad(unidadDiagonal)),
|
||||
)
|
||||
expect(result.current.escrituracionDiferida).toBe(false)
|
||||
})
|
||||
|
||||
|
||||
@@ -17,10 +17,11 @@ export const APARTADO_ESQUEMA_DEFAULT = 15000
|
||||
const MS_POR_MES = 30.4 * 86400000
|
||||
|
||||
/**
|
||||
* Mensualidades del diferimiento sugeridas para una unidad: los meses que
|
||||
* faltan hasta su fecha de entrega estimada (la que vive en el módulo de
|
||||
* captura: entrega programada/reprogramada de obra). Si la unidad no trae
|
||||
* fecha, cae al default del esquema (14). El vendedor puede ajustarla después.
|
||||
* Tope (y default) de mensualidades del diferimiento para una unidad: los
|
||||
* meses que faltan hasta su fecha de entrega estimada (la que vive en el
|
||||
* módulo de captura: entrega programada/reprogramada de obra). El diferimiento
|
||||
* nunca puede rebasar la entrega de la vivienda. Si la unidad no trae fecha,
|
||||
* cae al default global del esquema (14; tope 36).
|
||||
*/
|
||||
export function mensualidadesParaUnidad(u: Unidad | null, hoy: Date = new Date()): number {
|
||||
const fecha = u?.fechaEntrega
|
||||
@@ -101,18 +102,29 @@ export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizac
|
||||
|
||||
/** Elige unidad y, con ella, las mensualidades del diferimiento: los meses
|
||||
* que faltan para la entrega estimada de esa vivienda (dato del módulo de
|
||||
* captura). El vendedor puede ajustarlas después. */
|
||||
* captura). El vendedor puede ajustarlas después, sin rebasar la entrega. */
|
||||
const elegirUnidad = (u: Unidad | null) => {
|
||||
setUnidad(u)
|
||||
setNumMensualidades(mensualidadesParaUnidad(u))
|
||||
}
|
||||
|
||||
/** Tope de mensualidades: las que faltan para la entrega (36 sin fecha) */
|
||||
const topeMensualidades = () => mensualidadesParaUnidad(unidad)
|
||||
|
||||
const ajustarMensualidades = (n: number) => {
|
||||
setNumMensualidades(Math.max(MENSUALIDADES_MIN, Math.min(n, topeMensualidades())))
|
||||
}
|
||||
|
||||
const restaurar = (estado: EstadoRestaurar) => {
|
||||
setDesarrolloState(estado.desarrollo)
|
||||
setUnidad(estado.unidad)
|
||||
setAcabados(estado.acabados)
|
||||
setApartado(estado.apartado)
|
||||
setNumMensualidades(estado.numMensualidades ?? MENSUALIDADES_DEFAULT)
|
||||
// El diferimiento no puede rebasar la entrega: una cotización guardada con
|
||||
// más mensualidades se acota a las que faltan hoy para esa unidad
|
||||
setNumMensualidades(
|
||||
Math.min(estado.numMensualidades ?? MENSUALIDADES_DEFAULT, mensualidadesParaUnidad(estado.unidad)),
|
||||
)
|
||||
setEscrituracionDiferida(estado.escrituracionDiferida ?? false)
|
||||
}
|
||||
|
||||
@@ -160,7 +172,7 @@ export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizac
|
||||
setApartado,
|
||||
apartadoEfectivo,
|
||||
numMensualidades,
|
||||
setNumMensualidades,
|
||||
setNumMensualidades: ajustarMensualidades,
|
||||
escrituracionDiferida,
|
||||
setEscrituracionDiferida,
|
||||
precioLista,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/re
|
||||
import { MemoryRouter } from 'react-router-dom'
|
||||
import CotizadorPage from './CotizadorPage'
|
||||
import { INVENTARIO_SEED } from '../data/unidades'
|
||||
import { mensualidadesParaUnidad } from '../hooks/useCotizacion'
|
||||
|
||||
/**
|
||||
* Smoke del cierre de la cotización con el esquema único FRASA 2026:
|
||||
@@ -70,7 +71,8 @@ describe('CotizadorPage — barra sticky y guardado local', () => {
|
||||
expect(await screen.findByText('Esquema FRASA 2026 — conceptos')).toBeTruthy()
|
||||
expect(screen.getByText('Hoy')).toBeTruthy()
|
||||
expect(screen.getByText('A la firma')).toBeTruthy()
|
||||
expect(screen.getByRole('option', { name: '14 mensualidades' })).toBeTruthy()
|
||||
// El tope de mensualidades son los meses que faltan para la entrega
|
||||
expect(screen.getByRole('option', { name: `${mensualidadesParaUnidad(unidad)} mensualidades` })).toBeTruthy()
|
||||
|
||||
// Guardar con el servidor caído → folio local persistido en el dispositivo
|
||||
fireEvent.change(screen.getByLabelText(/Nombre del cliente/), {
|
||||
|
||||
@@ -4,7 +4,8 @@ import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import QRCode from 'qrcode'
|
||||
import { useUnidades } from '../hooks/useUnidades'
|
||||
import { useCotizacion } from '../hooks/useCotizacion'
|
||||
import { useCotizacion, mensualidadesParaUnidad } from '../hooks/useCotizacion'
|
||||
import { MENSUALIDADES_MAX } from '../lib/esquema-frasa'
|
||||
import DarkModeToggle from '../components/DarkModeToggle'
|
||||
import { SkeletonList } from "../components/Skeleton"
|
||||
import toast from "react-hot-toast"
|
||||
@@ -783,6 +784,7 @@ export default function CotizadorPage() {
|
||||
<EsquemaPanel
|
||||
esquema={resultado}
|
||||
fechaEntrega={unidad?.fechaEntrega ?? null}
|
||||
maxMensualidades={unidad ? mensualidadesParaUnidad(unidad) : MENSUALIDADES_MAX}
|
||||
numMensualidades={numMensualidades}
|
||||
onNumMensualidades={setNumMensualidades}
|
||||
escrituracionDiferida={escrituracionDiferida}
|
||||
|
||||
Reference in New Issue
Block a user