feat: esquema unico de cotizacion FRASA 2026 (reemplaza las 4 propuestas)

Flujo aprobado por el cliente (28 sep 2026), porcentajes globales para
todos los desarrollos:
- Apartado: cantidad libre (sin minimo), se acredita dentro del enganche
  de firma; el excedente se descuenta del enganche
- Enganche de firma: 2.5% del precio (saldo a firma = max(0, 2.5% - apartado))
- Enganche: 7.5%, diferible en N mensualidades (N a eleccion del vendedor, 1-36)
- Gastos de escrituracion: 5.5%, pago unico a la firma o diferido con el
  enganche (toggle)

- lib/esquema-frasa.ts (frontend) + cotizacion-esquema.service.ts (backend):
  modulos puros espejo, caso dorado M/apartado 5k/N=14 en tests de ambos
- Migracion 20260928120000: 7 columnas nuevas en Cotizacion (esquema,
  saldoFirma, engancheDiferible, escrituracion, etc.); legacy se rellena
  para compatibilidad de reportes (tipoPropuesta=FRASA_2026)
- UI: panel de linea de tiempo (Hoy -> Firma -> Mensualidades) sustituye a
  propuestas/comparadores; PDF y WhatsApp con el esquema unico
- API recalcula server-side; smoke end-to-end verificado contra inventario
- 743 tests API + 128 cotizador en verde
This commit is contained in:
2026-09-28 13:00:22 +00:00
parent 23d288d0aa
commit 61d1b84fc3
23 changed files with 1160 additions and 1051 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react'
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
@@ -10,9 +10,7 @@ import { SkeletonList } from "../components/Skeleton"
import toast from "react-hot-toast"
import { UnitSelector } from '../components/UnitSelector'
import { UnitInfoCard } from '../components/UnitInfoCard'
import { ProposalCard } from '../components/ProposalCard'
import { ComparadorPropuestas } from '../components/ComparadorPropuestas'
import { ComparadorUnidades } from '../components/ComparadorUnidades'
import { EsquemaPanel } from '../components/EsquemaPanel'
import { MisCotizaciones } from '../components/MisCotizaciones'
import { ModalCliente } from '../components/ModalCliente'
import { BuscadorCliente } from '../components/BuscadorCliente'
@@ -21,16 +19,13 @@ import { formatoMoneda, formatoFechaLarga } from '../lib/formatters'
import { calcularVigencia } from '../lib/vigencia'
import { folioCotizacion } from '../lib/folio'
import { textoCompartir, urlWhatsApp } from '../lib/compartir'
import { indiceMenorTotal, totalPagado, ENGANCHE_MAXIMO_PCT } from '../lib/calculations'
import { desarrolloEfectivo, idsCotizables, propuestaVigente } from '../lib/reglas'
import { desarrolloEfectivo, idsCotizables } from '../lib/reglas'
import {
getMisCotizaciones,
getCotizacion,
guardarCotizacion,
urlPublicaCotizacion,
ErrorApiCotizacion,
TIPO_A_API,
TIPO_DE_API,
type CotizacionGuardada,
} from '../services/api'
import { cargarCotizacionesLocales, guardarCotizacionLocal } from '../services/offline-db'
@@ -39,6 +34,10 @@ import type { Cliente, Unidad } from '../types'
/** Solo el inventario DISPONIBLE se puede cotizar (apartar/vender bloquea la unidad) */
const esDisponible = (u: Unidad): boolean => (u.status ?? '').trim().toUpperCase() === 'DISPONIBLE'
/** Una cotización guardada ya vive en el esquema único (filas nuevas) */
const esEsquemaNuevo = (c: CotizacionGuardada): boolean =>
c.esquema === 'FRASA_2026' && c.saldoFirma != null
const esquemaCliente = z.object({
nombre: z.string().min(3, 'El nombre del cliente es requerido (mínimo 3 caracteres)'),
email: z.union([z.literal(''), z.string().email('Correo electrónico inválido')]).optional(),
@@ -70,17 +69,13 @@ export default function CotizadorPage() {
apartado,
setApartado,
apartadoEfectivo,
enganchePct,
setEnganchePct,
errorEnganche,
propuestaIndex,
setPropuestaIndex,
precioLista,
numMensualidades,
setNumMensualidades,
escrituracionDiferida,
setEscrituracionDiferida,
precioLista,
resultado,
numMeses,
propuestas,
avisoPropuestaDesactivada,
restaurar,
nueva,
} = cotizacion
@@ -121,11 +116,6 @@ export default function CotizadorPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [desarrollos, desarrollo])
// La propuesta elegida dejó de estar disponible (la desactivó el admin)
useEffect(() => {
if (avisoPropuestaDesactivada !== null) setAviso(avisoPropuestaDesactivada)
}, [avisoPropuestaDesactivada])
useEffect(() => {
let vivo = true
/** Combina servidor + locales, sin duplicados, más recientes primero */
@@ -241,33 +231,9 @@ export default function CotizadorPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cotizacionPendiente, cargando, unidades])
// La propuesta elegida (para guardar/compartir); acotada por si cambia el desarrollo
const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))]
// Total pagado por el cliente con la propuesta elegida; null si no se puede conocer
const totalActivo = propuestaActiva ? totalPagado(propuestaActiva, apartadoEfectivo) : null
// Una unidad apartada/vendida no se cotiza: se avisa y se bloquean las acciones
const unidadNoDisponible = unidad !== null && !esDisponible(unidad)
const puedeGenerarPdf = Boolean(unidad) && !unidadNoDisponible && !errorEnganche && !guardando
// ---------- Preselección por defecto: la propuesta de menor total pagado ----------
// Al cambiar de unidad (o al elegir la primera) se preselecciona la propuesta
// más barata; la elección manual del vendedor se respeta hasta el siguiente
// cambio de unidad. handleRetomar marca saltarAuto: manda SU propuesta guardada.
const unidadPrevia = useRef<string | null | undefined>(undefined)
const saltarAuto = useRef(false)
useEffect(() => {
const codigo = unidad?.codigo ?? null
const cambioUnidad = unidadPrevia.current !== undefined && unidadPrevia.current !== codigo
unidadPrevia.current = codigo
if (!cambioUnidad || saltarAuto.current) {
saltarAuto.current = false
return
}
const totales = propuestas.map((p) => totalPagado(p, apartadoEfectivo))
const indiceMenor = indiceMenorTotal(totales.map((t) => t ?? Number.POSITIVE_INFINITY))
if (indiceMenor >= 0) setPropuestaIndex(indiceMenor)
}, [unidad?.codigo, propuestas, apartadoEfectivo])
const puedeGenerarPdf = Boolean(unidad) && !unidadNoDisponible && !guardando
/** true cuando la cotización vive solo en este dispositivo (el API no respondió) */
const esCotizacionLocal = (c: CotizacionGuardada): boolean => c.id.startsWith('local-')
@@ -275,24 +241,30 @@ export default function CotizadorPage() {
/** Instantánea local de la cotización con folio CTE-LOCAL-<timestamp> */
const cotizacionLocal = (cliente: Cliente): CotizacionGuardada => {
const timestamp = Date.now()
if (!unidad || !resultado) throw new Error('No hay esquema para guardar localmente')
return {
id: `local-${timestamp}`,
folio: `CTE-LOCAL-${timestamp}`,
unidadCodigo: unidad!.codigo,
unidadCodigo: unidad.codigo,
desarrollo,
clienteNombre: cliente.nombre,
clienteTelefono: cliente.telefono ?? null,
clienteEmail: cliente.email ?? null,
tipoPropuesta: TIPO_A_API[propuestaActiva.tipo],
esquema: 'FRASA_2026',
tipoPropuesta: 'FRASA_2026',
acabados,
apartado: apartadoEfectivo,
descuento: propuestaActiva.descuento,
descuento: 0,
precioLista,
precioFinal: propuestaActiva.precioFinal,
enganche: propuestaActiva.enganche,
numMensualidades: propuestaActiva.numMensualidades ?? 0,
mensualidad: propuestaActiva.mensualidad,
pagoFinal: propuestaActiva.pagoFinal,
precioFinal: precioLista,
saldoFirma: resultado.saldoFirma,
engancheDiferible: resultado.enganche,
escrituracion: resultado.escrituracion,
escrituracionDiferida,
enganche: resultado.enganche,
numMensualidades,
mensualidad: resultado.mensualidad,
pagoFinal: resultado.pagoUnicoFirma,
createdAt: new Date(timestamp).toISOString(),
}
}
@@ -305,19 +277,17 @@ export default function CotizadorPage() {
* devuelve null: se avisa y no se guarda nada.
*/
const guardarEnApi = async (cliente: Cliente): Promise<CotizacionGuardada | null> => {
if (!unidad || !propuestaActiva) return null
if (!unidad || !resultado) return null
try {
const guardada = await guardarCotizacion({
unidadCodigo: unidad.codigo,
clienteNombre: cliente.nombre,
...(cliente.email ? { clienteEmail: cliente.email } : {}),
...(cliente.telefono ? { clienteTelefono: cliente.telefono } : {}),
tipoPropuesta: TIPO_A_API[propuestaActiva.tipo],
acabados,
apartado: apartadoEfectivo,
descuento: propuestaActiva.descuento,
...(numMeses !== null ? { numMensualidades: numMeses } : {}),
...(enganchePct !== null && errorEnganche === null ? { enganchePct: enganchePct / 100 } : {}),
numMensualidades,
escrituracionDiferida,
...(oportunidadId !== null ? { oportunidadId } : {}),
})
const conFolio = { ...guardada, folio: guardada.folio ?? folioCotizacion(guardada.id) }
@@ -344,7 +314,7 @@ export default function CotizadorPage() {
}
const generarPdfConCliente = async (cliente: Cliente) => {
if (!unidad) return
if (!unidad || !resultado) return
setGuardando(true)
try {
const guardada = await guardarEnApi(cliente)
@@ -365,8 +335,8 @@ export default function CotizadorPage() {
acabados,
apartado: apartadoEfectivo,
precioLista,
// El PDF imprime SOLO la propuesta elegida por el vendedor
propuestas: propuestaActiva ? [propuestaActiva] : [],
// El PDF imprime SOLO el esquema único FRASA 2026
esquema: resultado,
cliente,
desarrollo: params,
folio,
@@ -432,11 +402,11 @@ export default function CotizadorPage() {
}
const handleCompartir = async () => {
if (!unidad || !propuestaActiva) return
if (!unidad || !resultado) return
const texto = textoCompartir({
unidadCodigo: unidad.codigo,
desarrolloNombre: params.nombre,
propuesta: propuestaActiva,
esquema: resultado,
apartado: apartadoEfectivo,
vigencia: calcularVigencia(),
...(ultimaGuardada
@@ -468,32 +438,32 @@ export default function CotizadorPage() {
setAviso(`La unidad ${c.unidadCodigo} ya no está en el inventario actual.`)
return
}
const tipo = TIPO_DE_API[c.tipoPropuesta]
const paramsGuardados = desarrolloEfectivo(reglas, c.desarrollo)
const idx = paramsGuardados.propuestas.filter(propuestaVigente).findIndex((p) => p.tipo === tipo)
if (idx < 0) {
setAviso(
`La cotización ${c.folio ?? ''} usa la propuesta ${c.tipoPropuesta}, que ya no está disponible para ${c.desarrollo}. Elige otra propuesta.`,
)
}
saltarAuto.current = true // no re-preseleccionar la más barata: manda la guardada
const nueva = esEsquemaNuevo(c)
restaurar({
desarrollo: c.desarrollo,
unidad: u,
acabados: c.acabados,
apartado: c.apartado,
descuento: c.descuento,
propuestaIndex: idx >= 0 ? idx : 0,
numMensualidades: c.numMensualidades > 0 ? c.numMensualidades : null,
// Las cotizaciones del esquema anterior retoman con los defaults del
// nuevo esquema (14 mensualidades, escrituración a la firma)
...(nueva
? {
numMensualidades: c.numMensualidades > 0 ? c.numMensualidades : undefined,
escrituracionDiferida: c.escrituracionDiferida,
}
: {}),
})
setValue('nombre', c.clienteNombre, { shouldValidate: true })
setValue('telefono', c.clienteTelefono ?? '', { shouldValidate: true })
setValue('email', c.clienteEmail ?? '', { shouldValidate: true })
setUltimaGuardada(c)
const avisoEsquema = nueva
? ''
: ` Usa el esquema anterior (${c.tipoPropuesta}); se cargó con el esquema FRASA 2026 y sus valores por defecto.`
setAviso(
esDisponible(u)
? `Cotización ${c.folio ?? ''} retomada.`
: `Cotización ${c.folio ?? ''} retomada, pero la unidad ${u.codigo} ya no está disponible (estatus: ${u.status}): no se puede cotizar de nuevo.`,
? `Cotización ${c.folio ?? ''} retomada.${avisoEsquema}`
: `Cotización ${c.folio ?? ''} retomada, pero la unidad ${u.codigo} ya no está disponible (estatus: ${u.status}): no se puede cotizar de nuevo.${avisoEsquema}`,
)
}
@@ -639,67 +609,6 @@ export default function CotizadorPage() {
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">{formatoMoneda(apartado)}</p>
</div>
<div>
<label htmlFor="enganche" className={etiquetaCampo}>
Enganche (%) — opcional
</label>
<input
id="enganche"
type="number"
min={2.5}
max={ENGANCHE_MAXIMO_PCT}
step={0.5}
placeholder="Según propuesta"
value={enganchePct ?? ''}
onChange={(e) => {
const crudo = e.target.value
if (crudo === '') {
setEnganchePct(null)
return
}
const v = Number(crudo)
setEnganchePct(Number.isFinite(v) ? v : null)
}}
className={errorEnganche ? campoInputError : campoInput}
/>
{errorEnganche ? (
<p className="mt-1 text-xs text-red-700">{errorEnganche}</p>
) : (
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
Aplica a crédito bancario e Infonavit/Cofinavit; rango válido 2.5% a{' '}
{ENGANCHE_MAXIMO_PCT}% (lo que permite el Excel)
{unidad ? ` (${formatoMoneda((precioLista * Math.max(enganchePct ?? 2.5, 2.5)) / 100)})` : ''}
</p>
)}
</div>
<div>
<label htmlFor="mensualidades" className={etiquetaCampo}>
Mensualidades
</label>
<select
id="mensualidades"
value={numMensualidades ?? ''}
onChange={(e) => {
const v = e.target.value
setNumMensualidades(v === '' ? null : Number(v))
}}
className={campoInput}
>
<option value="">Calcular automáticamente</option>
{[6, 12, 14, 18, 24].map((n) => (
<option key={n} value={n}>
{n} mensualidades
</option>
))}
</select>
{numMeses !== null && (
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
Se usarán {numMeses} mensualidades para el cálculo
</p>
)}
</div>
<BuscadorCliente
onSeleccion={(lead) => {
setValue('nombre', lead.nombre, { shouldValidate: true })
@@ -783,33 +692,22 @@ export default function CotizadorPage() {
{unidad && <UnitInfoCard unidad={unidad} numMeses={numMeses} />}
</aside>
{/* Panel de propuestas */}
{/* Panel del esquema único FRASA 2026 */}
<section className="min-w-0">
{!unidad ? (
{!unidad || !resultado ? (
<div className="flex h-64 items-center justify-center rounded-xl border border-dashed border-[#D1D5DB] bg-white dark:border-[#334155] dark:bg-[#0F172A]">
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
Selecciona una unidad para ver las propuestas comerciales.
Selecciona una unidad para ver el esquema de cotización.
</p>
</div>
) : (
<div className="space-y-8">
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{propuestas.map((p, i) => (
<ProposalCard
key={p.tipo}
numero={i + 1}
titulo={p.titulo}
propuesta={p}
/>
))}
</div>
<ComparadorPropuestas
precioLista={precioLista}
apartado={apartadoEfectivo}
propuestas={propuestas}
titulos={propuestas.map((p) => p.titulo)}
/>
</div>
<EsquemaPanel
esquema={resultado}
numMensualidades={numMensualidades}
onNumMensualidades={setNumMensualidades}
escrituracionDiferida={escrituracionDiferida}
onEscrituracionDiferida={setEscrituracionDiferida}
/>
)}
</section>
</div>
@@ -822,44 +720,24 @@ export default function CotizadorPage() {
error={errorMias}
onRetomar={handleRetomar}
/>
<ComparadorUnidades
unidades={unidades}
tipo={propuestaActiva?.tipo ?? 'CREDITO_BANCARIO'}
apartado={apartadoEfectivo}
acabados={acabados}
reglas={reglas}
/>
</div>
</main>
{/* Barra fija de cierre: propuesta elegida + acciones, siempre visibles */}
{/* Barra fija de cierre: resumen del esquema + acciones, siempre visibles */}
<div className="fixed inset-x-0 bottom-0 z-40 border-t border-[#E5E7EB] bg-white/95 shadow-[0_-4px_16px_rgba(15,23,42,0.08)] backdrop-blur dark:border-[#1E293B] dark:bg-[#0F172A]/95">
<div className="mx-auto flex max-w-7xl flex-col gap-3 px-4 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-6 lg:px-8">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{propuestaActiva ? (
<>
<span className="inline-flex max-w-full items-center rounded-full bg-accent/10 px-3 py-1.5 text-xs font-semibold text-accent">
<span className="truncate">
Propuesta elegida: #{propuestas.indexOf(propuestaActiva) + 1} {propuestaActiva.titulo}
{totalActivo !== null ? ` · Total ${formatoMoneda(totalActivo)}` : ''}
</span>
{resultado ? (
<span className="inline-flex max-w-full items-center rounded-full bg-accent/10 px-3 py-1.5 text-xs font-semibold text-accent">
<span className="truncate">
Esquema FRASA 2026 · Hoy {formatoMoneda(resultado.apartado)} · A la firma{' '}
{formatoMoneda(resultado.pagoUnicoFirma)} · {resultado.numMensualidades} ×{' '}
{formatoMoneda(resultado.mensualidad)}
</span>
<select
aria-label="Cambiar propuesta elegida"
value={Math.min(propuestaIndex, propuestas.length - 1)}
onChange={(e) => setPropuestaIndex(Number(e.target.value))}
className="rounded-lg border border-[#D1D5DB] bg-white px-2 py-1.5 text-xs transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
>
{propuestas.map((p, i) => (
<option key={p.tipo} value={i}>
#{i + 1} {p.titulo}
</option>
))}
</select>
</>
</span>
) : (
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">
Selecciona una unidad para elegir propuesta, guardar y generar el PDF.
Selecciona una unidad para ver el esquema, guardar y generar el PDF.
</p>
)}
{aviso && (