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:
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user