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,158 +0,0 @@
import { useState } from 'react'
import type { PropuestaResultado } from '../lib/calculations'
import { indiceMenorTotal, totalPagado } from '../lib/calculations'
import { formatoMoneda, formatoPorcentaje } from '../lib/formatters'
interface Props {
precioLista: number
apartado: number
propuestas: PropuestaResultado[]
titulos: string[]
}
interface Fila {
etiqueta: string
obtener: (p: PropuestaResultado, i: number) => string
destacada?: boolean
}
/** Sección colapsable con la tabla comparativa de las 4 propuestas */
export function ComparadorPropuestas({ precioLista, apartado, propuestas, titulos }: Props) {
const [abierto, setAbierto] = useState(false)
const totales = propuestas.map((p) => totalPagado(p, apartado))
// null (plazo desconocido) nunca gana el menor total
const ganador = indiceMenorTotal(totales.map((t) => t ?? Number.POSITIVE_INFINITY))
const filas: Fila[] = [
{ etiqueta: 'Precio de lista', obtener: () => formatoMoneda(precioLista) },
{
etiqueta: 'Descuento',
obtener: (p) => `${formatoMoneda(p.montoDescuento)} (${formatoPorcentaje(p.descuento)})`,
},
{ etiqueta: 'Precio final c/desc.', obtener: (p) => formatoMoneda(p.precioFinal) },
{ etiqueta: 'Enganche', obtener: (p) => formatoMoneda(p.enganche) },
{
etiqueta: 'Mensualidad',
obtener: (p) =>
p.notaMensualidades
? `— (${p.notaMensualidades})`
: p.mensualidad === null
? 'N/A'
: `${p.numMensualidades} × ${formatoMoneda(p.mensualidad)}`,
},
{
etiqueta: 'Total mensualidades',
obtener: (p) =>
p.notaMensualidades
? '—'
: p.mensualidadesTotal === null
? 'N/A'
: formatoMoneda(p.mensualidadesTotal),
},
{ etiqueta: 'Pago final', obtener: (p) => formatoMoneda(p.pagoFinal) },
{
etiqueta: 'Total pagado',
obtener: (_p, i) => (totales[i] === null ? '—' : formatoMoneda(totales[i])),
destacada: true,
},
{
etiqueta: 'Diferencia vs precio de lista',
obtener: (_p, i) => (totales[i] === null ? '—' : formatoMoneda(totales[i] - precioLista)),
},
]
/** Borde acento que enmarca la columna con el menor total pagado */
const bordeColumna = (i: number, posicion: 'inicio' | 'media' | 'fin'): string => {
if (i !== ganador) return ''
const laterales = 'border-x-2 border-x-accent'
if (posicion === 'inicio') return `${laterales} border-t-2 border-t-accent`
if (posicion === 'fin') return `${laterales} border-b-2 border-b-accent`
return laterales
}
return (
<section aria-labelledby="comparador-titulo">
<div className="flex flex-wrap items-center justify-between gap-4">
<h2
id="comparador-titulo"
className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]"
>
Comparar propuestas
</h2>
<button
type="button"
aria-expanded={abierto}
aria-controls="comparador-contenido"
onClick={() => setAbierto((v) => !v)}
className="rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
{abierto ? 'Ocultar comparación' : 'Comparar propuestas'}
</button>
</div>
{abierto && (
<div id="comparador-contenido" className="mt-6 rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<div className="overflow-x-auto">
<table className="w-full min-w-[720px] border-collapse text-sm">
<thead>
<tr className="bg-[#F8FAFC] dark:bg-[#0F172A]">
<th scope="col" className="w-48 p-3 text-left align-bottom">
<span className="sr-only">Concepto</span>
</th>
{propuestas.map((p, i) => (
<th
key={p.tipo}
scope="col"
className={`p-3 text-right align-bottom ${bordeColumna(i, 'inicio')}`}
>
<span className="block text-xs font-semibold uppercase tracking-wider text-[#111827] dark:text-[#E2E8F0]">
{titulos[i] ?? p.tipo}
</span>
{i === ganador && (
<span className="mt-2 inline-block rounded-full bg-blue-50 px-2.5 py-0.5 text-xs font-medium text-blue-700">
Menor total
</span>
)}
</th>
))}
</tr>
</thead>
<tbody>
{filas.map((fila, indiceFila) => (
<tr key={fila.etiqueta} className="hover:bg-[#F8FAFC] dark:bg-[#0F172A]">
<th
scope="row"
className="border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 text-left text-xs font-medium text-[#64748B] dark:text-[#94A3B8]"
>
{fila.etiqueta}
</th>
{propuestas.map((p, i) => (
<td
key={p.tipo}
className={`border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 text-right whitespace-nowrap ${
fila.destacada
? `font-semibold ${i === ganador ? 'text-accent' : 'text-[#111827] dark:text-[#E2E8F0]'}`
: 'text-[#111827] dark:text-[#E2E8F0]'
} ${bordeColumna(
i,
indiceFila === filas.length - 1 ? 'fin' : 'media',
)}`}
>
{fila.obtener(p, i)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<p className="mt-6 text-xs text-[#64748B] dark:text-[#94A3B8]">
El enganche ya incluye la deducción del apartado. El total pagado suma apartado +
enganche + mensualidades + pago final.
</p>
</div>
)}
</section>
)
}

View File

@@ -1,203 +0,0 @@
import { useMemo, useState } from 'react'
import type { Unidad, TipoPropuesta } from '../types'
import { compararUnidades, type ColumnaComparador } from '../lib/comparadorUnidades'
import { formatoMoneda } from '../lib/formatters'
import { SIN_REGLAS, desarrolloEfectivo, idsCotizables, type ReglasDesarrollo } from '../lib/reglas'
interface Props {
unidades: Unidad[]
/** Tipo de propuesta activa en el cotizador (se aplica a ambas unidades) */
tipo: TipoPropuesta
apartado: number
acabados: boolean
/** Reglas comerciales por desarrollo (vacío => constantes) */
reglas?: ReglasDesarrollo
}
const formatoM2 = (v: number | null): string => (v === null ? '—' : `${v.toLocaleString('es-MX')} m²`)
interface Fila {
etiqueta: string
obtener: (c: ColumnaComparador) => string
destacada?: boolean
}
/**
* Comparador de 2 unidades (del mismo o distinto desarrollo), lado a lado,
* con la propuesta activa calculada por el motor con los parámetros de cada
* desarrollo (las reglas del admin con respaldo en las constantes). Solo ofrece
* unidades DISPONIBLES de desarrollos activos en el cotizador: no se compara
* (ni se cotiza) inventario apartado, vendido, bloqueado o desactivado.
*/
export function ComparadorUnidades({ unidades, tipo, apartado, acabados, reglas = SIN_REGLAS }: Props) {
const [abierto, setAbierto] = useState(false)
const [codigoA, setCodigoA] = useState('')
const [codigoB, setCodigoB] = useState('')
const desarrollos = useMemo(() => idsCotizables(reglas), [reglas])
const disponibles = useMemo(
() =>
unidades.filter(
(u) =>
(u.status ?? '').trim().toUpperCase() === 'DISPONIBLE' &&
desarrollos.includes(u.desarrollo ?? 'DIAGONAL'),
),
[unidades, desarrollos],
)
const porCodigo = useMemo(() => {
const m = new Map<string, Unidad>()
for (const u of disponibles) m.set(`${u.desarrollo ?? 'DIAGONAL'}|${u.codigo}`, u)
return m
}, [disponibles])
const unidadA = porCodigo.get(codigoA) ?? null
const unidadB = porCodigo.get(codigoB) ?? null
const comparacion = useMemo(
() =>
unidadA && unidadB
? compararUnidades(unidadA, unidadB, tipo, apartado, acabados, reglas)
: null,
[unidadA, unidadB, tipo, apartado, acabados, reglas],
)
const filas: Fila[] = [
{ etiqueta: 'Desarrollo', obtener: (c) => c.desarrolloNombre },
{ etiqueta: 'Modelo', obtener: (c) => c.unidad.modelo },
{ etiqueta: 'Estatus', obtener: (c) => c.unidad.status },
{ etiqueta: 'Sup. construcción', obtener: (c) => formatoM2(c.supConstruccion) },
{ etiqueta: 'Precio de lista', obtener: (c) => formatoMoneda(c.precioLista) },
{ etiqueta: 'Precio final c/desc.', obtener: (c) => formatoMoneda(c.propuesta.precioFinal) },
{ etiqueta: 'Enganche', obtener: (c) => formatoMoneda(c.propuesta.enganche) },
{
etiqueta: 'Mensualidad',
obtener: (c) =>
c.propuesta.notaMensualidades
? `— (${c.propuesta.notaMensualidades})`
: c.propuesta.mensualidad === null
? 'N/A'
: `${c.propuesta.numMensualidades} × ${formatoMoneda(c.propuesta.mensualidad)}`,
},
{ etiqueta: 'Pago final', obtener: (c) => formatoMoneda(c.propuesta.pagoFinal) },
{
etiqueta: 'Total pagado',
obtener: (c) => (c.total === null ? '—' : formatoMoneda(c.total)),
destacada: true,
},
]
const selector = (id: string, valor: string, onChange: (v: string) => void, etiqueta: string) => (
<div>
<label htmlFor={id} className="mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
{etiqueta}
</label>
<select
id={id}
value={valor}
onChange={(e) => onChange(e.target.value)}
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none"
>
<option value="">Selecciona una unidad…</option>
{desarrollos.map((dev) => {
const delDesarrollo = disponibles.filter((u) => (u.desarrollo ?? 'DIAGONAL') === dev)
if (delDesarrollo.length === 0) return null
return (
<optgroup key={dev} label={desarrolloEfectivo(reglas, dev).nombre}>
{delDesarrollo.map((u) => (
<option key={`${dev}|${u.codigo}`} value={`${dev}|${u.codigo}`}>
{u.codigo} — {u.modelo}
</option>
))}
</optgroup>
)
})}
</select>
</div>
)
return (
<section aria-labelledby="comparador-unidades-titulo">
<div className="flex flex-wrap items-center justify-between gap-4">
<h2
id="comparador-unidades-titulo"
className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]"
>
Comparar unidades
</h2>
<button
type="button"
aria-expanded={abierto}
aria-controls="comparador-unidades-contenido"
onClick={() => setAbierto((v) => !v)}
className="rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
{abierto ? 'Ocultar comparación' : 'Comparar 2 unidades'}
</button>
</div>
{abierto && (
<div id="comparador-unidades-contenido" className="mt-6 rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<div className="grid gap-6 md:grid-cols-2">
{selector('comparador-a', codigoA, setCodigoA, 'Unidad A')}
{selector('comparador-b', codigoB, setCodigoB, 'Unidad B')}
</div>
{!comparacion ? (
<p className="mt-6 text-sm text-[#64748B] dark:text-[#94A3B8]">
Selecciona dos unidades para ver la comparación lado a lado.
</p>
) : (
<div className="mt-6 overflow-x-auto">
<table className="w-full min-w-[480px] border-collapse text-sm">
<thead>
<tr className="bg-[#F8FAFC] dark:bg-[#0F172A]">
<th scope="col" className="w-48 p-3 text-left">
<span className="sr-only">Concepto</span>
</th>
{[comparacion.a, comparacion.b].map((c) => (
<th
key={`${c.desarrolloId}|${c.unidad.codigo}`}
scope="col"
className="p-3 text-right text-xs font-semibold uppercase tracking-wider text-[#111827] dark:text-[#E2E8F0]"
>
{c.unidad.codigo}
</th>
))}
</tr>
</thead>
<tbody>
{filas.map((fila) => (
<tr key={fila.etiqueta} className="hover:bg-[#F8FAFC] dark:bg-[#0F172A]">
<th
scope="row"
className="border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 text-left text-xs font-medium text-[#64748B] dark:text-[#94A3B8]"
>
{fila.etiqueta}
</th>
{[comparacion.a, comparacion.b].map((c) => (
<td
key={`${c.desarrolloId}|${c.unidad.codigo}`}
className={`border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 text-right whitespace-nowrap ${
fila.destacada ? 'font-semibold text-accent' : 'text-[#111827] dark:text-[#E2E8F0]'
}`}
>
{fila.obtener(c)}
</td>
))}
</tr>
))}
</tbody>
</table>
<p className="mt-6 text-xs text-[#64748B] dark:text-[#94A3B8]">
Solo se ofrecen unidades DISPONIBLES. Cada unidad se calcula con los parámetros de
su desarrollo y el mismo apartado. El enganche ya incluye la deducción del apartado.
</p>
</div>
)}
</div>
)}
</section>
)
}

View File

@@ -0,0 +1,164 @@
import {
MENSUALIDADES_MAX,
MENSUALIDADES_MIN,
PCT_ENGANCHE,
PCT_ENGANCHE_FIRMA,
PCT_ESCRITURACION,
type EsquemaFrasaResultado,
} from '../lib/esquema-frasa'
import { formatoMoneda, formatoPorcentaje } from '../lib/formatters'
interface Props {
/** Esquema FRASA 2026 calculado (siempre presente: solo se renderiza con unidad) */
esquema: EsquemaFrasaResultado
numMensualidades: number
onNumMensualidades: (n: number) => void
escrituracionDiferida: boolean
onEscrituracionDiferida: (v: boolean) => void
}
const pasoBase =
'flex-1 rounded-xl border border-[#E5E7EB] bg-white p-4 dark:border-[#1E293B] dark:bg-[#0F172A]'
const pasoTitulo = 'text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]'
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.
*/
export function EsquemaPanel({
esquema,
numMensualidades,
onNumMensualidades,
escrituracionDiferida,
onEscrituracionDiferida,
}: Props) {
return (
<div className="space-y-6">
{/* Línea de tiempo del esquema */}
<div className="flex flex-col gap-4 sm:flex-row">
<div className={pasoBase}>
<p className={pasoTitulo}>Hoy</p>
<p className={pasoMonto}>{formatoMoneda(esquema.apartado)}</p>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">Apartado (aparta la unidad)</p>
</div>
<div className={pasoBase}>
<p className={pasoTitulo}>A la firma</p>
<p className={pasoMonto}>{formatoMoneda(esquema.pagoUnicoFirma)}</p>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
Saldo de enganche de firma
{!escrituracionDiferida && ' + escrituración'}
</p>
</div>
<div className={pasoBase}>
<p className={pasoTitulo}>Después</p>
<p className={pasoMonto}>
{esquema.numMensualidades} × {formatoMoneda(esquema.mensualidad)}
</p>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
Total diferible {formatoMoneda(esquema.baseDiferible)}
</p>
</div>
</div>
{/* Detalle de conceptos */}
<section className="rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<h2 className="mb-4 text-xs font-semibold uppercase tracking-wider text-[#111827] dark:text-[#E2E8F0]">
Esquema FRASA 2026 — conceptos
</h2>
<dl className="divide-y divide-[#E5E7EB] dark:divide-[#334155]">
<div className="flex flex-col gap-1 py-2 sm:flex-row sm:items-center sm:justify-between">
<dt className="text-sm text-[#64748B] dark:text-[#94A3B8]">
Enganche de firma ({formatoPorcentaje(PCT_ENGANCHE_FIRMA)})
</dt>
<dd className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">
{formatoMoneda(esquema.engancheFirmaTotal)}
<span className="ml-2 text-xs font-normal text-[#64748B] dark:text-[#94A3B8]">
− apartado acreditado → saldo a la firma {formatoMoneda(esquema.saldoFirma)}
</span>
</dd>
</div>
{esquema.excedenteApartado > 0 && (
<div className="flex flex-col gap-1 py-2 sm:flex-row sm:items-center sm:justify-between">
<dt className="text-sm text-[#64748B] dark:text-[#94A3B8]">
Apartado que excede el 2.5%
</dt>
<dd className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">
{formatoMoneda(esquema.excedenteApartado)}
<span className="ml-2 text-xs font-normal text-[#64748B] dark:text-[#94A3B8]">
acreditado al enganche
</span>
</dd>
</div>
)}
<div className="flex flex-col gap-1 py-2 sm:flex-row sm:items-center sm:justify-between">
<dt className="text-sm text-[#64748B] dark:text-[#94A3B8]">
Enganche ({formatoPorcentaje(PCT_ENGANCHE)}) diferible
</dt>
<dd className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">
{formatoMoneda(esquema.enganche)}
</dd>
</div>
<div className="flex flex-col gap-1 py-2 sm:flex-row sm:items-center sm:justify-between">
<dt className="text-sm text-[#64748B] dark:text-[#94A3B8]">
Gastos de escrituración ({formatoPorcentaje(PCT_ESCRITURACION)})
</dt>
<dd className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">
{formatoMoneda(esquema.escrituracion)}
<span className="ml-2 text-xs font-normal text-[#64748B] dark:text-[#94A3B8]">
{escrituracionDiferida ? 'diferidos con el enganche' : 'pagados a la firma'}
</span>
</dd>
</div>
<div className="flex flex-col gap-1 py-2 sm:flex-row sm:items-center sm:justify-between">
<dt className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
Total del esquema (15.5% del precio)
</dt>
<dd className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
{formatoMoneda(esquema.totalPagado)}
</dd>
</div>
</dl>
</section>
{/* Controles del diferimiento */}
<section className="flex flex-col gap-4 rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm sm:flex-row sm:items-end">
<div className="flex-1">
<label htmlFor="mensualidades-esquema" className="mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
Mensualidades del diferimiento
</label>
<select
id="mensualidades-esquema"
value={numMensualidades}
onChange={(e) => onNumMensualidades(Number(e.target.value))}
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(
(n) => (
<option key={n} value={n}>
{n} mensualidades
</option>
),
)}
</select>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
De {MENSUALIDADES_MIN} a {MENSUALIDADES_MAX}; 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]">
<input
id="escrituracion-diferida"
type="checkbox"
checked={escrituracionDiferida}
onChange={(e) => onEscrituracionDiferida(e.target.checked)}
className="h-4 w-4 rounded border-[#D1D5DB] accent-accent"
/>
Diferir escrituración con el enganche
</label>
</section>
</div>
)
}

View File

@@ -11,6 +11,9 @@ interface Props {
}
const TITULO_PROPUESTA: Record<string, string> = {
// Esquema único FRASA 2026 (cotizaciones nuevas); las viejas muestran su
// tipoPropuesta legacy (BANCARIO, INFONAVIT, CONTADO, SEMI_CONTADO)
FRASA_2026: 'Esquema FRASA',
BANCARIO: 'Crédito Bancario',
INFONAVIT: 'Crédito Infonavit-Cofinavit',
COFINAVIT: 'Crédito Infonavit-Cofinavit',

View File

@@ -1,62 +0,0 @@
import type { PropuestaResultado } from '../lib/calculations'
import { formatoMoneda } from '../lib/formatters'
interface Props {
numero: number
titulo: string
propuesta: PropuestaResultado
}
function Renglon({ etiqueta, valor, nota }: { etiqueta: string; valor: string; nota?: string }) {
return (
<div className="flex items-baseline justify-between gap-2 py-2">
<span className="text-xs font-medium text-[#64748B] dark:text-[#94A3B8]">
{etiqueta}
{nota && (
<span className="mt-0.5 block text-xs font-normal text-[#64748B] dark:text-[#94A3B8]">
{nota}
</span>
)}
</span>
<span className="text-lg font-medium text-[#111827] dark:text-[#E2E8F0]">{valor}</span>
</div>
)
}
/** Tarjeta de una propuesta comercial (el descuento ya no es editable: usa el del Excel) */
export function ProposalCard({ numero, titulo, propuesta }: Props) {
return (
<article className="flex flex-col rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm transition-all duration-200 hover:shadow-md">
<header className="mb-6 border-b border-[#E5E7EB] pb-4">
<p className="text-xs font-semibold uppercase tracking-wider text-accent">
Propuesta comercial #{numero}
</p>
<h2 className="mt-1 text-xl font-semibold text-[#111827] dark:text-[#E2E8F0]">{titulo}</h2>
</header>
<div className="divide-y divide-[#E5E7EB]">
{propuesta.descuento > 0 && (
<Renglon etiqueta="Monto de descuento" valor={formatoMoneda(propuesta.montoDescuento)} />
)}
<Renglon etiqueta="Precio final c/desc." valor={formatoMoneda(propuesta.precioFinal)} />
<Renglon
etiqueta="Enganche"
nota="incluye deducción del apartado"
valor={formatoMoneda(propuesta.enganche)}
/>
{propuesta.notaMensualidades ? (
<Renglon etiqueta="Mensualidades" nota={propuesta.notaMensualidades} valor="—" />
) : propuesta.mensualidad !== null && propuesta.mensualidadesTotal !== null ? (
<Renglon
etiqueta="Mensualidades"
nota={`${propuesta.numMensualidades} × ${formatoMoneda(propuesta.mensualidad)}`}
valor={formatoMoneda(propuesta.mensualidadesTotal)}
/>
) : (
<Renglon etiqueta="Mensualidades" valor="N/A" />
)}
<Renglon etiqueta="Pago final" valor={formatoMoneda(propuesta.pagoFinal)} />
</div>
</article>
)
}

View File

@@ -1,31 +1,22 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest'
import { act, cleanup, renderHook } from '@testing-library/react'
import { useCotizacion } from './useCotizacion'
import { reglasDesdeApi, SIN_REGLAS, type ReglasDesarrollo } from '../lib/reglas'
import { DESARROLLOS } from '../data/desarrollos'
import { APARTADO_ESQUEMA_DEFAULT, useCotizacion } from './useCotizacion'
import { SIN_REGLAS } from '../lib/reglas'
import { MENSUALIDADES_DEFAULT, calcularEsquemaFrasa } from '../lib/esquema-frasa'
import { INVENTARIO_SEED } from '../data/unidades'
/**
* Reglas comerciales configurables en el cotizador: las propuestas desactivadas
* por el admin dejan de ofrecerse y, si la elegida se desactiva, se avisa y se
* cae a la primera vigente.
* Estado central con el esquema único FRASA 2026: apartado libre (default
* $15,000), mensualidades del diferimiento (default 14) y escrituración a la
* firma o diferida. Ya no hay propuestas: el resultado sale de
* calcularEsquemaFrasa (mismo motor que el API).
*/
const unidadDiagonal = INVENTARIO_SEED.find((u) => u.desarrollo === 'DIAGONAL')!
function reglasDiagonales(activa: (tipo: string) => boolean): ReglasDesarrollo {
return reglasDesdeApi({
DIAGONAL: {
propuestas: DESARROLLOS['DIAGONAL']!.propuestas.map((p) => ({ ...p, activa: activa(p.tipo) })),
},
})
}
function montar(reglas: ReglasDesarrollo = SIN_REGLAS) {
const hook = renderHook(({ reglas }) => useCotizacion(reglas), {
initialProps: { reglas: reglas as ReglasDesarrollo },
})
function montar() {
const hook = renderHook(() => useCotizacion(SIN_REGLAS))
act(() => {
hook.result.current.setUnidad(unidadDiagonal)
})
@@ -34,75 +25,128 @@ function montar(reglas: ReglasDesarrollo = SIN_REGLAS) {
afterEach(cleanup)
describe('useCotizacion y las reglas del admin', () => {
it('sin reglas ofrece las 4 propuestas de siempre', () => {
describe('useCotizacion — esquema único FRASA 2026', () => {
it('arranca con apartado $15,000, 14 mensualidades y escrituración a la firma', () => {
const { result } = montar()
expect(result.current.propuestas.map((p) => p.tipo)).toEqual([
'CREDITO_BANCARIO',
'CREDITO_INFONAVIT_COFINAVIT',
'CONTADO',
'SEMI_CONTADO',
])
expect(result.current.avisoPropuestaDesactivada).toBeNull()
expect(result.current.apartado).toBe(APARTADO_ESQUEMA_DEFAULT)
expect(result.current.apartadoEfectivo).toBe(15000)
expect(result.current.numMensualidades).toBe(MENSUALIDADES_DEFAULT)
expect(result.current.escrituracionDiferida).toBe(false)
})
it('una propuesta desactivada no se ofrece ni se puede elegir', () => {
const { result } = montar(reglasDiagonales((tipo) => tipo !== 'CONTADO'))
it('el resultado cuadra con calcularEsquemaFrasa sobre el precio de lista', () => {
const { result } = montar()
const { unidad, acabados } = result.current
expect(result.current.propuestas.map((p) => p.tipo)).toEqual([
'CREDITO_BANCARIO',
'CREDITO_INFONAVIT_COFINAVIT',
'SEMI_CONTADO',
])
})
it('si el admin desactiva la propuesta elegida, avisa y cae a la primera vigente', () => {
const { result, rerender } = montar()
// El vendedor elige Contado (índice 2)
act(() => {
result.current.setPropuestaIndex(2)
const esperado = calcularEsquemaFrasa({
precio: result.current.precioLista,
apartado: 15000,
numMensualidades: 14,
escrituracionDiferida: false,
})
expect(result.current.propuestas[2]?.tipo).toBe('CONTADO')
// El admin la desactiva (llegan reglas nuevas)
rerender({ reglas: reglasDiagonales((tipo) => tipo !== 'CONTADO') })
expect(result.current.propuestas.map((p) => p.tipo)).not.toContain('CONTADO')
expect(result.current.propuestaIndex).toBe(0)
expect(result.current.avisoPropuestaDesactivada).toContain('Contado')
expect(result.current.avisoPropuestaDesactivada).toContain('Crédito Bancario')
expect(result.current.resultado).toEqual(esperado)
// El esquema siempre liquida el 15.5% del precio (2.5 + 7.5 + 5.5)
expect(result.current.resultado?.totalPagado).toBeCloseTo(result.current.precioLista * 0.155, 2)
expect(unidad).toBe(unidadDiagonal)
expect(acabados).toBe(false)
})
it('los descuentos por defecto salen de las reglas configuradas', () => {
const { result } = montar(
reglasDesdeApi({
DIAGONAL: {
propuestas: DESARROLLOS['DIAGONAL']!.propuestas.map((p) =>
p.tipo === 'CONTADO' ? { ...p, descuentoDefault: 0.06 } : p,
),
},
}),
)
expect(result.current.descuentos.contado).toBe(0.06)
expect(result.current.descuentos.semiContado).toBe(0.01)
it('sin unidad no hay resultado (panel vacío)', () => {
const { result } = montar()
act(() => {
result.current.setUnidad(null)
})
expect(result.current.resultado).toBeNull()
expect(result.current.precioLista).toBe(0)
})
it('las reglas cambian los números de las propuestas', () => {
const { result } = montar(
reglasDesdeApi({
DIAGONAL: {
propuestas: DESARROLLOS['DIAGONAL']!.propuestas.map((p) =>
p.tipo === 'CREDITO_BANCARIO' ? { ...p, pctEnganche: 0.05 } : p,
),
},
}),
)
it('el apartado libre alimenta el saldo a firma; el excedente acredita al enganche', () => {
const { result } = montar()
const excedente = Math.round(result.current.precioLista * 0.025) + 5000
const bancario = result.current.propuestas.find((p) => p.tipo === 'CREDITO_BANCARIO')
const precioLista = result.current.precioLista
expect(bancario?.enganche).toBeCloseTo(0.05 * precioLista - result.current.apartadoEfectivo, 2)
act(() => {
result.current.setApartado(excedente)
})
const r = result.current.resultado!
expect(r.excedenteApartado).toBeGreaterThan(0)
expect(r.saldoFirma).toBe(0)
// El total no cambia: el excedente se mueve de concepto, no se cobra dos veces
expect(r.totalPagado).toBeCloseTo(result.current.precioLista * 0.155, 2)
})
it('las mensualidades elegidas recalculan la mensualidad sobre el diferible', () => {
const { result } = montar()
act(() => {
result.current.setNumMensualidades(12)
})
const r = result.current.resultado!
expect(r.numMensualidades).toBe(12)
expect(r.mensualidad).toBeCloseTo(r.baseDiferible / 12, 6)
})
it('escrituración diferida: sube el diferible y baja el pago a la firma', () => {
const { result } = montar()
act(() => {
result.current.setEscrituracionDiferida(true)
})
const r = result.current.resultado!
expect(r.escrituracionDiferida).toBe(true)
expect(r.baseDiferible).toBeCloseTo(r.enganche + r.escrituracion, 6)
expect(r.pagoUnicoFirma).toBeCloseTo(r.saldoFirma, 6)
})
it('restaurar carga una cotización guardada del esquema (N y escrituración)', () => {
const { result } = montar()
act(() => {
result.current.restaurar({
desarrollo: 'DIAGONAL',
unidad: unidadDiagonal,
acabados: true,
apartado: 20000,
numMensualidades: 24,
escrituracionDiferida: true,
})
})
expect(result.current.acabados).toBe(true)
expect(result.current.apartadoEfectivo).toBe(20000)
expect(result.current.numMensualidades).toBe(24)
expect(result.current.escrituracionDiferida).toBe(true)
expect(result.current.resultado?.numMensualidades).toBe(24)
})
it('restaurar una cotización legacy (sin N ni escrituración) cae a los defaults', () => {
const { result } = montar()
act(() => {
result.current.restaurar({
desarrollo: 'DIAGONAL',
unidad: unidadDiagonal,
acabados: false,
apartado: 20000,
})
})
expect(result.current.numMensualidades).toBe(MENSUALIDADES_DEFAULT)
expect(result.current.escrituracionDiferida).toBe(false)
})
it('nueva reinicia apartado, mensualidades y escrituración, y limpia la unidad', () => {
const { result } = montar()
act(() => {
result.current.setApartado(40000)
result.current.setNumMensualidades(6)
result.current.setEscrituracionDiferida(true)
})
act(() => {
result.current.nueva()
})
expect(result.current.unidad).toBeNull()
expect(result.current.apartado).toBe(APARTADO_ESQUEMA_DEFAULT)
expect(result.current.numMensualidades).toBe(MENSUALIDADES_DEFAULT)
expect(result.current.escrituracionDiferida).toBe(false)
})
})

View File

@@ -1,25 +1,20 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type { Unidad, DescuentosPropuestas, ParametrosDesarrollo } from '../types'
import {
APARTADO_DEFAULT,
calcularPropuestas,
obtenerPrecioLista,
resolverNumMensualidades,
validarEnganchePct,
type PropuestaResultado,
} from '../lib/calculations'
import { useMemo, useState } from 'react'
import type { Unidad, ParametrosDesarrollo } from '../types'
import { obtenerPrecioLista, resolverNumMensualidades } from '../lib/calculations'
import { DESARROLLO_DEFAULT } from '../data/desarrollos'
import {
SIN_REGLAS,
descuentosDefaultEfectivo,
desarrolloEfectivo,
propuestasVigentes,
type ReglasDesarrollo,
} from '../lib/reglas'
MENSUALIDADES_DEFAULT,
calcularEsquemaFrasa,
type EsquemaFrasaResultado,
} from '../lib/esquema-frasa'
import { SIN_REGLAS, desarrolloEfectivo, type ReglasDesarrollo } from '../lib/reglas'
/** Apartado con el que arranca (y se reinicia) el cotizador: cantidad libre */
export const APARTADO_ESQUEMA_DEFAULT = 15000
interface UseCotizacionResult {
desarrollo: string
/** Cambia de desarrollo: limpia la unidad elegida y reinicia los descuentos */
/** Cambia de desarrollo: limpia la unidad elegida */
setDesarrollo: (id: string) => void
params: ParametrosDesarrollo
unidad: Unidad | null
@@ -30,25 +25,17 @@ interface UseCotizacionResult {
setApartado: (v: number) => void
/** Apartado aplicado a los cálculos (acotado a >= 0; el vendedor puede apartar con cualquier monto, incluso $0) */
apartadoEfectivo: number
/** Enganche capturado en PORCENTAJE (10 = 10%); null = % del Excel de cada propuesta */
enganchePct: number | null
setEnganchePct: (v: number | null) => void
/** Mensaje de error del enganche capturado; null si es válido */
errorEnganche: string | null
descuentos: DescuentosPropuestas
setDescuento: (clave: keyof DescuentosPropuestas, valor: number) => void
/** Índice de la propuesta elegida (para guardar/compartir) */
propuestaIndex: number
setPropuestaIndex: (i: number) => void
/** Número de mensualidades del diferimiento (elección del vendedor, 1..36) */
numMensualidades: number
setNumMensualidades: (n: number) => void
/** true = la escrituración (5.5%) se difiere con el enganche; false = se paga a la firma */
escrituracionDiferida: boolean
setEscrituracionDiferida: (v: boolean) => void
precioLista: number
/** Número de mensualidades elegido por el vendedor; null = usa el calculado */
numMensualidades: number | null
setNumMensualidades: (n: number | null) => void
/** Número efectivo de mensualidades (la elegida o la calculada) */
/** Esquema FRASA 2026 calculado; null mientras no hay unidad elegida */
resultado: EsquemaFrasaResultado | null
/** Meses restantes para la entrega de la unidad (ficha técnica); null si no se pueden resolver */
numMeses: number | null
propuestas: PropuestaResultado[]
/** Aviso cuando el admin desactivó la propuesta que estaba elegida; null si no aplica */
avisoPropuestaDesactivada: string | null
/** Restaura el estado completo desde una cotización guardada (historial) */
restaurar: (estado: EstadoRestaurar) => void
/** Reinicia la cotización (botón "Nueva") */
@@ -61,60 +48,34 @@ export interface EstadoRestaurar {
unidad: Unidad
acabados: boolean
apartado: number
/** Descuento (fracción) de la propuesta elegida; se aplica sobre su clave */
descuento?: number
propuestaIndex?: number
/** Mensualidades elegidas al guardar la cotización (null = usar el plazo calculado) */
numMensualidades?: number | null
/** Mensualidades del diferimiento guardadas (default 14) */
numMensualidades?: number
/** Escrituración diferida guardada (default false) */
escrituracionDiferida?: boolean
}
/**
* Estado central de la cotización: desarrollo, unidad, acabados, apartado y
* descuentos. Las reglas comerciales llegan del API (con respaldo en las
* constantes): si el admin desactiva una propuesta, deja de ofrecerse y, si era
* la elegida, se avisa y se cae a la primera vigente.
* Estado central de la cotización con el esquema único FRASA 2026:
* desarrollo, unidad, acabados, apartado libre, mensualidades del
* diferimiento (1..36, default 14) y si la escrituración se difiere o se paga
* a la firma. El resultado sale de calcularEsquemaFrasa (módulo puro, mismo
* motor que el API). Ya no hay propuestas: descuentos, enganche en % e índice
* de propuesta desaparecieron con las 4 propuestas.
*/
export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizacionResult {
const [desarrollo, setDesarrolloState] = useState<string>(DESARROLLO_DEFAULT)
const [unidad, setUnidad] = useState<Unidad | null>(null)
const [acabados, setAcabados] = useState(false)
const [apartado, setApartado] = useState<number>(APARTADO_DEFAULT)
const [enganchePct, setEnganchePct] = useState<number | null>(null)
const [descuentos, setDescuentos] = useState<DescuentosPropuestas>(
descuentosDefaultEfectivo(reglas, DESARROLLO_DEFAULT),
)
const [propuestaIndex, setPropuestaIndex] = useState(0)
const [numMensualidades, setNumMensualidades] = useState<number | null>(null)
const [avisoPropuestaDesactivada, setAvisoPropuestaDesactivada] = useState<string | null>(null)
const [apartado, setApartado] = useState<number>(APARTADO_ESQUEMA_DEFAULT)
const [numMensualidades, setNumMensualidades] = useState<number>(MENSUALIDADES_DEFAULT)
const [escrituracionDiferida, setEscrituracionDiferida] = useState(false)
const params = desarrolloEfectivo(reglas, desarrollo)
// Los descuentos por defecto se re-aplican cuando cambian las reglas del
// desarrollo (p. ej. al llegar las del API), sin pisar lo que capturó el
// vendedor mientras las reglas no cambian.
const descuentosAplicados = useRef<string>('')
const defaults = useMemo(
() => descuentosDefaultEfectivo(reglas, desarrollo),
[reglas, desarrollo],
)
useEffect(() => {
const clave = JSON.stringify(defaults)
if (descuentosAplicados.current === clave) return
descuentosAplicados.current = clave
setDescuentos(defaults)
}, [defaults])
const setDesarrollo = (id: string) => {
setDesarrolloState(id)
setUnidad(null)
setAcabados(false)
setDescuentos(descuentosDefaultEfectivo(reglas, id))
setPropuestaIndex(0)
setNumMensualidades(null)
}
const setDescuento = (clave: keyof DescuentosPropuestas, valor: number) => {
setDescuentos((prev) => ({ ...prev, [clave]: valor }))
}
const restaurar = (estado: EstadoRestaurar) => {
@@ -122,38 +83,23 @@ export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizac
setUnidad(estado.unidad)
setAcabados(estado.acabados)
setApartado(estado.apartado)
const base = descuentosDefaultEfectivo(reglas, estado.desarrollo)
const paramsRestaurados = desarrolloEfectivo(reglas, estado.desarrollo)
const vigentes = propuestasVigentes(paramsRestaurados)
if (estado.descuento !== undefined) {
const idx = estado.propuestaIndex ?? 0
const clave = vigentes[idx]?.claveDescuento
if (clave) base[clave] = estado.descuento
}
setDescuentos(base)
setPropuestaIndex(estado.propuestaIndex ?? 0)
setNumMensualidades(estado.numMensualidades ?? null)
setNumMensualidades(estado.numMensualidades ?? MENSUALIDADES_DEFAULT)
setEscrituracionDiferida(estado.escrituracionDiferida ?? false)
}
const nueva = () => {
setUnidad(null)
setAcabados(false)
setApartado(APARTADO_DEFAULT)
setEnganchePct(null)
setDescuentos(descuentosDefaultEfectivo(reglas, desarrollo))
setPropuestaIndex(0)
setNumMensualidades(null)
setAvisoPropuestaDesactivada(null)
setApartado(APARTADO_ESQUEMA_DEFAULT)
setNumMensualidades(MENSUALIDADES_DEFAULT)
setEscrituracionDiferida(false)
}
const numMesesCalculado = useMemo(
const numMeses = useMemo(
() => (unidad ? resolverNumMensualidades(unidad, params) : null),
[unidad, params],
)
/** Número efectivo de mensualidades: la elegida por el vendedor, o la calculada */
const numMeses = numMensualidades ?? numMesesCalculado
const precioLista = useMemo(
() => (unidad ? obtenerPrecioLista(unidad, acabados) : 0),
[unidad, acabados],
@@ -163,46 +109,15 @@ export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizac
// incluso $0; solo se acota a >= 0 como guarda contra capturas raras
const apartadoEfectivo = Math.max(0, apartado)
// Enganche personalizado: null = default del Excel; inválido no entra al cálculo
const errorEnganche = validarEnganchePct(enganchePct)
const engancheEfectivo = errorEnganche === null && enganchePct !== null ? enganchePct / 100 : null
const propuestas = useMemo(() => {
if (!unidad) return []
return calcularPropuestas({
unidad,
acabados,
const resultado = useMemo<EsquemaFrasaResultado | null>(() => {
if (!unidad || precioLista <= 0) return null
return calcularEsquemaFrasa({
precio: precioLista,
apartado: apartadoEfectivo,
descuentos,
desarrollo,
reglas: params,
enganchePct: engancheEfectivo,
numMeses: numMeses ?? undefined,
numMensualidades,
escrituracionDiferida,
})
}, [unidad, acabados, apartadoEfectivo, descuentos, desarrollo, params, engancheEfectivo, numMeses])
// Propuesta elegida: si el índice se sale de la lista vigente (p. ej. el admin
// desactivó una propuesta) se cae a la primera y, si la que estaba elegida ya
// no existe, se avisa con claridad.
const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))]
const tipoPrevio = useRef<{ tipo: string; titulo: string } | null>(null)
useEffect(() => {
const previo = tipoPrevio.current
const vigentes = propuestasVigentes(params)
if (previo !== null && !vigentes.some((p) => p.tipo === previo.tipo)) {
const sustituto = propuestas[0]?.titulo ?? 'otra propuesta'
setAvisoPropuestaDesactivada(
`El administrador desactivó la propuesta ${previo.titulo} para ${params.nombre}. Se seleccionó ${sustituto}; elige otra si lo prefieres.`,
)
setPropuestaIndex(0)
} else if (propuestaIndex >= propuestas.length) {
setPropuestaIndex(0)
}
tipoPrevio.current = propuestaActiva
? { tipo: propuestaActiva.tipo, titulo: propuestaActiva.titulo }
: null
}, [params, propuestas, propuestaActiva, propuestaIndex])
}, [unidad, precioLista, apartadoEfectivo, numMensualidades, escrituracionDiferida])
return {
desarrollo,
@@ -215,19 +130,13 @@ export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizac
apartado,
setApartado,
apartadoEfectivo,
enganchePct,
setEnganchePct,
errorEnganche,
descuentos,
setDescuento,
propuestaIndex,
setPropuestaIndex,
precioLista,
numMensualidades,
setNumMensualidades,
escrituracionDiferida,
setEscrituracionDiferida,
precioLista,
resultado,
numMeses,
propuestas,
avisoPropuestaDesactivada,
restaurar,
nueva,
}

View File

@@ -1,10 +1,11 @@
import type { PropuestaResultado } from './calculations'
import type { EsquemaFrasaResultado } from './esquema-frasa'
import { formatoMoneda, formatoFechaLarga } from './formatters'
export interface DatosCompartir {
unidadCodigo: string
desarrolloNombre: string
propuesta: PropuestaResultado
/** Esquema único FRASA 2026 calculado */
esquema: EsquemaFrasaResultado
apartado: number
vigencia: Date
/** Folio y URL pública solo existen si la cotización ya se guardó en el API */
@@ -13,8 +14,9 @@ export interface DatosCompartir {
}
/**
* Texto resumen de la propuesta, listo para copiar/pegar en WhatsApp.
* Incluye unidad, desarrollo, propuesta elegida, montos y vigencia.
* Texto resumen del esquema FRASA 2026, listo para copiar/pegar en WhatsApp.
* Incluye unidad, desarrollo, línea de tiempo (hoy / firma / mensualidades)
* y vigencia.
*/
export function textoCompartir(d: DatosCompartir): string {
const lineas: string[] = []
@@ -23,20 +25,14 @@ export function textoCompartir(d: DatosCompartir): string {
)
lineas.push(`Desarrollo: ${d.desarrolloNombre}`)
lineas.push(`Unidad: ${d.unidadCodigo}`)
lineas.push(`Propuesta: ${d.propuesta.titulo}`)
lineas.push(`Precio final c/desc: ${formatoMoneda(d.propuesta.precioFinal)}`)
lineas.push(`Apartado: ${formatoMoneda(d.apartado)}`)
lineas.push(`Enganche: ${formatoMoneda(d.propuesta.enganche)}`)
if (d.propuesta.notaMensualidades) {
lineas.push(`Mensualidades: — (${d.propuesta.notaMensualidades})`)
} else if (d.propuesta.mensualidad !== null) {
lineas.push(
`Mensualidades: ${d.propuesta.numMensualidades} × ${formatoMoneda(d.propuesta.mensualidad)}`,
)
} else {
lineas.push('Mensualidades: N/A')
}
lineas.push(`Pago final: ${formatoMoneda(d.propuesta.pagoFinal)}`)
lineas.push(`Precio de lista: ${formatoMoneda(d.esquema.precio)}`)
lineas.push(`Apartado (hoy): ${formatoMoneda(d.apartado)}`)
lineas.push(`Pago a la firma: ${formatoMoneda(d.esquema.pagoUnicoFirma)}`)
lineas.push(
`Mensualidades: ${d.esquema.numMensualidades} × ${formatoMoneda(d.esquema.mensualidad)} (total diferible ${formatoMoneda(d.esquema.baseDiferible)})`,
)
lineas.push(`Escrituración 5.5%: ${d.esquema.escrituracionDiferida ? 'diferida con el enganche' : `pagada a la firma (${formatoMoneda(d.esquema.escrituracion)})`}`)
lineas.push(`Total del esquema: ${formatoMoneda(d.esquema.totalPagado)}`)
lineas.push(`Vigencia: hasta el ${formatoFechaLarga(d.vigencia)}`)
if (d.urlPublica) lineas.push(`Consulta: ${d.urlPublica}`)
return lineas.join('\n')

View File

@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest'
import {
calcularEsquemaFrasa,
validarEsquemaInput,
PCT_ENGANCHE_FIRMA,
PCT_ENGANCHE,
PCT_ESCRITURACION,
} from './esquema-frasa'
/** Caso dorado del cliente (28 sep 2026): casa de $1,000,000 */
const CASO = { precio: 1000000, apartado: 15000, numMensualidades: 14 }
describe('calcularEsquemaFrasa — caso dorado $1,000,000 / apartado $15,000', () => {
it('escrituración de contado: firma $65,000 y mensualidades de $75,000/14', () => {
const r = calcularEsquemaFrasa({ ...CASO, escrituracionDiferida: false })
expect(r.engancheFirmaTotal).toBe(25000) // 2.5%
expect(r.saldoFirma).toBe(10000) // 25,000 − 15,000 apartado acreditado
expect(r.excedenteApartado).toBe(0)
expect(r.enganche).toBe(75000) // 7.5%
expect(r.escrituracion).toBe(55000) // 5.5%
expect(r.pagoUnicoFirma).toBe(65000) // 10,000 + 55,000
expect(r.baseDiferible).toBe(75000)
expect(r.mensualidad).toBeCloseTo(75000 / 14, 2) // 5,357.14
expect(r.totalPagado).toBeCloseTo(1000000 * (PCT_ENGANCHE_FIRMA + PCT_ENGANCHE + PCT_ESCRITURACION) + 0, 2)
})
it('escrituración diferida: firma $10,000 y mensualidades de $130,000/14', () => {
const r = calcularEsquemaFrasa({ ...CASO, escrituracionDiferida: true })
expect(r.saldoFirma).toBe(10000)
expect(r.pagoUnicoFirma).toBe(10000)
expect(r.baseDiferible).toBe(130000) // 75,000 + 55,000
expect(r.mensualidad).toBeCloseTo(130000 / 14, 2) // 9,285.71
})
it('el total siempre cuadra: apartado + firma + diferible', () => {
for (const dif of [false, true]) {
const r = calcularEsquemaFrasa({ ...CASO, escrituracionDiferida: dif })
expect(r.apartado + r.pagoUnicoFirma + r.baseDiferible).toBeCloseTo(r.totalPagado, 6)
// y equivale al 15.5% del precio (2.5 + 7.5 + 5.5) salvo excedentes
expect(r.totalPagado).toBeCloseTo(155000, 2)
}
})
})
describe('calcularEsquemaFrasa — apartado vs enganche de firma', () => {
it('apartado exactamente el 2.5%: saldo a firma 0', () => {
const r = calcularEsquemaFrasa({ precio: 1000000, apartado: 25000, numMensualidades: 14, escrituracionDiferida: false })
expect(r.saldoFirma).toBe(0)
expect(r.excedenteApartado).toBe(0)
expect(r.enganche).toBe(75000)
})
it('apartado mayor al 2.5%: el excedente se acredita al enganche', () => {
const r = calcularEsquemaFrasa({ precio: 1000000, apartado: 30000, numMensualidades: 14, escrituracionDiferida: false })
expect(r.saldoFirma).toBe(0)
expect(r.excedenteApartado).toBe(5000)
expect(r.enganche).toBe(70000) // 75,000 − 5,000
expect(r.totalPagado).toBeCloseTo(155000, 2) // no se cobra dos veces: 30k + 70k + 55k
})
it('apartado mínimo (sin mínimo fijo): $1 aparta igual', () => {
const r = calcularEsquemaFrasa({ precio: 1000000, apartado: 1, numMensualidades: 14, escrituracionDiferida: false })
expect(r.saldoFirma).toBe(24999)
})
})
describe('calcularEsquemaFrasa — mensualidades', () => {
it('N=1: todo el diferible en una sola mensualidad', () => {
const r = calcularEsquemaFrasa({ precio: 1000000, apartado: 15000, numMensualidades: 1, escrituracionDiferida: true })
expect(r.mensualidad).toBe(130000)
})
it('validación: rechaza entradas inválidas', () => {
expect(validarEsquemaInput({ precio: 0, apartado: 0, numMensualidades: 14, escrituracionDiferida: false })).toMatch(/precio/i)
expect(validarEsquemaInput({ precio: 1000000, apartado: -5, numMensualidades: 14, escrituracionDiferida: false })).toMatch(/apartado/i)
expect(validarEsquemaInput({ precio: 1000000, apartado: 2000000, numMensualidades: 14, escrituracionDiferida: false })).toMatch(/superar/i)
expect(validarEsquemaInput({ precio: 1000000, apartado: 15000, numMensualidades: 0, escrituracionDiferida: false })).toMatch(/mensualidad/i)
expect(validarEsquemaInput({ precio: 1000000, apartado: 15000, numMensualidades: 14.5, escrituracionDiferida: false })).toMatch(/entero/i)
expect(validarEsquemaInput({ precio: 1000000, apartado: 15000, numMensualidades: 14, escrituracionDiferida: false })).toBeNull()
})
})

View File

@@ -0,0 +1,100 @@
/**
* Esquema de cotización FRASA 2026 (flujo único, aprobado por el cliente):
*
* 1. APARTADO: cantidad libre (sin mínimo), aparta la unidad. Hoy.
* 2. ENGANCHE DE FIRMA: 2.5% del precio. El apartado se acredita DENTRO de
* este concepto → lo que se paga a la firma es el saldo restante
* (si el apartado supera el 2.5%, el excedente pasa al enganche).
* 3. ENGANCHE: 7.5% del precio, diferible en N mensualidades (N a elección
* del vendedor).
* 4. GASTOS DE ESCRITURACIÓN: 5.5% del precio. El cliente los salda en un
* pago a la firma O los difiere junto con el enganche.
*
* Módulo puro (sin UI, sin API) para probarlo a fondo. Porcentajes globales
* para todos los desarrollos (confirmado por el cliente el 28 sep 2026).
*/
export const PCT_ENGANCHE_FIRMA = 0.025
export const PCT_ENGANCHE = 0.075
export const PCT_ESCRITURACION = 0.055
export const MENSUALIDADES_DEFAULT = 14
export const MENSUALIDADES_MIN = 1
export const MENSUALIDADES_MAX = 36
export interface EsquemaFrasaInput {
/** Precio de la unidad (ya resuelto acabados/precio único) */
precio: number
/** Apartado: cantidad libre (>= 0) */
apartado: number
/** Número de mensualidades del diferimiento (elección del vendedor) */
numMensualidades: number
/** true = la escrituración se difiere con el enganche; false = se paga a la firma */
escrituracionDiferida: boolean
}
export interface EsquemaFrasaResultado {
precio: number
apartado: number
/** 2.5% del precio (concepto completo) */
engancheFirmaTotal: number
/** Lo que se paga a la firma por el enganche de firma (>= 0) */
saldoFirma: number
/** Apartado que excedió el 2.5% y se acredita al enganche (>= 0) */
excedenteApartado: number
/** Enganche del 7.5%, neto del excedente del apartado */
enganche: number
escrituracion: number
escrituracionDiferida: boolean
/** Todo lo que se liquida a la firma: saldoFirma + escrituración si no se difiere */
pagoUnicoFirma: number
/** Lo que entra al diferimiento: enganche (+ escrituración si se difiere) */
baseDiferible: number
numMensualidades: number
mensualidad: number
/** apartado + firma + diferible — debe cuadrar con el precio saldado */
totalPagado: number
}
/** Error de validación de entrada; null si es válida */
export function validarEsquemaInput(input: EsquemaFrasaInput): string | null {
if (!Number.isFinite(input.precio) || input.precio <= 0) return 'El precio debe ser mayor a cero'
if (!Number.isFinite(input.apartado) || input.apartado < 0) return 'El apartado no puede ser negativo'
if (input.apartado > input.precio) return 'El apartado no puede superar el precio de la propiedad'
if (!Number.isInteger(input.numMensualidades)) return 'Las mensualidades deben ser un número entero'
if (input.numMensualidades < MENSUALIDADES_MIN) return `Mínimo ${MENSUALIDADES_MIN} mensualidad`
if (input.numMensualidades > MENSUALIDADES_MAX) return `Máximo ${MENSUALIDADES_MAX} mensualidades`
return null
}
export function calcularEsquemaFrasa(input: EsquemaFrasaInput): EsquemaFrasaResultado {
const error = validarEsquemaInput(input)
if (error) throw new Error(error)
const { precio, apartado, numMensualidades, escrituracionDiferida } = input
const engancheFirmaTotal = precio * PCT_ENGANCHE_FIRMA
const saldoFirma = Math.max(0, engancheFirmaTotal - apartado)
const excedenteApartado = Math.max(0, apartado - engancheFirmaTotal)
const enganche = precio * PCT_ENGANCHE - excedenteApartado
const escrituracion = precio * PCT_ESCRITURACION
const pagoUnicoFirma = saldoFirma + (escrituracionDiferida ? 0 : escrituracion)
const baseDiferible = enganche + (escrituracionDiferida ? escrituracion : 0)
const mensualidad = baseDiferible / numMensualidades
const totalPagado = apartado + pagoUnicoFirma + baseDiferible
return {
precio,
apartado,
engancheFirmaTotal,
saldoFirma,
excedenteApartado,
enganche,
escrituracion,
escrituracionDiferida,
pagoUnicoFirma,
baseDiferible,
numMensualidades,
mensualidad,
totalPagado,
}
}

View File

@@ -2,7 +2,8 @@ import { describe, expect, it } from 'vitest'
import { folioCotizacion } from './folio'
import { textoCompartir, telefonoWhatsApp, urlWhatsApp } from './compartir'
import { compararUnidades } from './comparadorUnidades'
import { APARTADO_DEFAULT, calcularPropuestas, DESCUENTOS_DEFAULT, totalPagado } from './calculations'
import { calcularEsquemaFrasa } from './esquema-frasa'
import { APARTADO_DEFAULT, totalPagado } from './calculations'
import { UNIDADES_SEED } from '../data/unidades'
import { UNIDADES_ATELIER } from '../data/unidades-atelier'
import { DESARROLLOS } from '../data/desarrollos'
@@ -16,30 +17,31 @@ describe('folioCotizacion', () => {
})
describe('textoCompartir', () => {
const propuesta = calcularPropuestas({
unidad: UNIDADES_SEED[0],
acabados: true,
apartado: APARTADO_DEFAULT,
descuentos: { ...DESCUENTOS_DEFAULT },
numMeses: 14,
})[0]
// Caso dorado del cliente: $1,000,000, apartado $15,000, N=14
const esquema = calcularEsquemaFrasa({
precio: 1000000,
apartado: 15000,
numMensualidades: 14,
escrituracionDiferida: false,
})
const base = {
unidadCodigo: 'U-101',
desarrolloNombre: 'Diagonal',
propuesta,
apartado: APARTADO_DEFAULT,
esquema,
apartado: 15000,
vigencia: new Date('2026-08-31T00:00:00'),
}
it('incluye unidad, desarrollo, propuesta, montos y vigencia', () => {
it('incluye unidad, desarrollo, línea de tiempo del esquema y vigencia', () => {
const texto = textoCompartir({ ...base, folio: 'COT-3F8A1B', urlPublica: 'https://x/api/cotizaciones/1/publico' })
expect(texto).toContain('Cotización COT-3F8A1B')
expect(texto).toContain('Desarrollo: Diagonal')
expect(texto).toContain('Unidad: U-101')
expect(texto).toContain(`Propuesta: ${propuesta.titulo}`)
expect(texto).toContain(`Enganche: ${formatoMoneda(propuesta.enganche)}`)
expect(texto).toContain(`Pago final: ${formatoMoneda(propuesta.pagoFinal)}`)
expect(texto).toContain(`Apartado (hoy): ${formatoMoneda(15000)}`)
expect(texto).toContain(`Pago a la firma: ${formatoMoneda(65000)}`)
expect(texto).toContain(`Mensualidades: 14 × ${formatoMoneda(75000 / 14)}`)
expect(texto).toContain(`Total del esquema: ${formatoMoneda(155000)}`)
expect(texto).toContain('Vigencia: hasta el 31 de agosto de 2026')
expect(texto).toContain('Consulta: https://x/api/cotizaciones/1/publico')
})
@@ -50,15 +52,16 @@ describe('textoCompartir', () => {
expect(texto).not.toContain('Consulta:')
})
it('contado (mensualidad null) muestra Mensualidades: N/A', () => {
const contado = calcularPropuestas({
unidad: UNIDADES_SEED[0],
acabados: true,
apartado: APARTADO_DEFAULT,
descuentos: { ...DESCUENTOS_DEFAULT },
numMeses: 14,
}).find((p) => p.tipo === 'CONTADO')!
expect(textoCompartir({ ...base, propuesta: contado })).toContain('Mensualidades: N/A')
it('escrituración diferida: lo dice en el texto', () => {
const diferido = calcularEsquemaFrasa({
precio: 1000000,
apartado: 15000,
numMensualidades: 14,
escrituracionDiferida: true,
})
const texto = textoCompartir({ ...base, esquema: diferido })
expect(texto).toContain('Escrituración 5.5%: diferida con el enganche')
expect(texto).toContain(`Pago a la firma: ${formatoMoneda(10000)}`)
})
})

View File

@@ -1,26 +1,27 @@
import { describe, it, expect } from 'vitest'
import { crearDocumentoPdf, textoCuotaMantenimiento } from './pdf'
import { calcularPropuestas, APARTADO_DEFAULT, DESCUENTOS_DEFAULT } from './calculations'
import { calcularEsquemaFrasa } from './esquema-frasa'
import { UNIDADES_SEED } from '../data/unidades'
import { DESARROLLOS, LISTA_DESARROLLOS } from '../data/desarrollos'
import { formatoMoneda } from './formatters'
/** Esquema único FRASA 2026: caso dorado $1,000,000 / apartado $15,000 / N=14 */
const esquemaDorado = calcularEsquemaFrasa({
precio: 1000000,
apartado: 15000,
numMensualidades: 14,
escrituracionDiferida: false,
})
describe('crearDocumentoPdf', () => {
it('construye el documento ANEXO A sin errores y con el nombre correcto', () => {
const unidad = UNIDADES_SEED[0]
const propuestas = calcularPropuestas({
unidad,
acabados: true,
apartado: APARTADO_DEFAULT,
descuentos: { ...DESCUENTOS_DEFAULT },
numMeses: 14,
})
const { doc, nombreArchivo } = crearDocumentoPdf({
unidad,
acabados: true,
apartado: APARTADO_DEFAULT,
apartado: 15000,
precioLista: unidad.precioConAcabados,
propuestas,
esquema: { ...esquemaDorado, precio: unidad.precioConAcabados },
cliente: { nombre: 'Juan Pérez' },
})
expect(nombreArchivo).toMatch(/^cotizacion_U-101_\d{4}-\d{2}-\d{2}\.pdf$/)
@@ -30,24 +31,38 @@ describe('crearDocumentoPdf', () => {
expect(texto.length).toBeGreaterThan(0)
})
it('imprime folio, vigencia y QR cuando la cotización ya está guardada', () => {
it('imprime la línea de tiempo del esquema (hoy / firma / mensualidades)', () => {
const unidad = UNIDADES_SEED[0]
const propuestas = calcularPropuestas({
const { doc } = crearDocumentoPdf({
unidad,
acabados: true,
apartado: APARTADO_DEFAULT,
descuentos: { ...DESCUENTOS_DEFAULT },
numMeses: 14,
apartado: 15000,
precioLista: unidad.precioConAcabados,
esquema: esquemaDorado,
cliente: { nombre: 'Juan Pérez' },
})
const pdf = new TextDecoder('latin1').decode(doc.output('arraybuffer'))
expect(pdf).toContain('ESQUEMA FRASA 2026')
// jspdf escapa los paréntesis en el stream: se valida el texto sin ellos
expect(pdf).toContain('APARTADO')
expect(pdf).toContain('ENGANCHE DE FIRMA')
expect(pdf).toContain('GASTOS DE ESCRITURACIÓN')
expect(pdf).toContain(formatoMoneda(esquemaDorado.pagoUnicoFirma))
expect(pdf).toContain('14 × ')
expect(pdf).toContain(formatoMoneda(esquemaDorado.totalPagado))
})
it('imprime folio, vigencia y QR cuando la cotización ya está guardada', () => {
const unidad = UNIDADES_SEED[0]
// PNG 1x1 válido; en la app lo genera la librería qrcode
const png1x1 =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='
const { doc } = crearDocumentoPdf({
unidad,
acabados: true,
apartado: APARTADO_DEFAULT,
apartado: 15000,
precioLista: unidad.precioConAcabados,
propuestas,
esquema: esquemaDorado,
cliente: { nombre: 'Juan Pérez' },
folio: 'COT-3F8A1B',
qrDataUrl: png1x1,
@@ -58,19 +73,12 @@ describe('crearDocumentoPdf', () => {
it('imprime el folio local CTE-LOCAL-* cuando el servidor no respondió', () => {
const unidad = UNIDADES_SEED[0]
const propuestas = calcularPropuestas({
unidad,
acabados: true,
apartado: APARTADO_DEFAULT,
descuentos: { ...DESCUENTOS_DEFAULT },
numMeses: 14,
})
const { doc } = crearDocumentoPdf({
unidad,
acabados: true,
apartado: APARTADO_DEFAULT,
apartado: 15000,
precioLista: unidad.precioConAcabados,
propuestas,
esquema: esquemaDorado,
cliente: { nombre: 'Juan Pérez' },
folio: 'CTE-LOCAL-1757568000000',
})

View File

@@ -1,7 +1,8 @@
import { jsPDF } from 'jspdf'
import autoTable from 'jspdf-autotable'
import type { Unidad, Cliente, ParametrosDesarrollo } from '../types'
import type { PropuestaResultado } from './calculations'
import type { EsquemaFrasaResultado } from './esquema-frasa'
import { PCT_ENGANCHE, PCT_ENGANCHE_FIRMA, PCT_ESCRITURACION } from './esquema-frasa'
import { formatoMoneda, formatoPorcentaje, formatoFechaLarga, fechaISOHoy } from './formatters'
import { DESARROLLOS, DESARROLLO_DEFAULT } from '../data/desarrollos'
@@ -10,8 +11,8 @@ export interface PdfInput {
acabados: boolean
apartado: number
precioLista: number
/** Propuestas a imprimir; desde la app llega SOLO la propuesta elegida */
propuestas: PropuestaResultado[]
/** Esquema único FRASA 2026 a imprimir */
esquema: EsquemaFrasaResultado
cliente: Cliente
/** Parámetros del desarrollo; por defecto DIAGONAL */
desarrollo?: ParametrosDesarrollo
@@ -30,7 +31,7 @@ export const textoCuotaMantenimiento = (cuota: number | null): string =>
cuota === null ? 'PENDIENTE' : formatoMoneda(cuota)
const DISCLAIMER =
'ESTA COTIZACION NO INCLUYE GASTOS DE ESCRITURACION. LOS VALORES ANTERIORES SON EN PESOS, MERAMENTE INFORMATIVOS, POR LO QUE ESTE DOCUMENTO NO CONSTITUYE PREAPROBACIÓN Y POR LO TANTO NO COMPROMETE AL DESARROLLADOR.\n\nMONTOS, PORCENTAJES Y PRECIOS EN LA PRESENTE COTIZACIÓN PUEDEN CAMBIAR SIN PREVIO AVISO.\n\nEL MONTO TOTAL DE ENGANCHE INCLUYE LA DEDUCCIÓN DEL MONTO DE APARTADO.\n\nLA PRESENTE COTIZACIÓN TIENE UNA VIGENCIA DE 3 DÍAS NATURALES A PARTIR DE LA FECHA DE EMISIÓN, PARA CONGELAR EL PRECIO DE VENTA ES NECESARIO REALIZAR EL PAGO DE SU APARTADO COMPLETO Y TENDRÁ 7 DÍAS PARA FORMALIZAR Y NO PERDER LA UBICACIÓN.'
'ESTA COTIZACION INCLUYE LOS GASTOS DE ESCRITURACION (5.5% DEL PRECIO), PAGADEROS A LA FIRMA O DIFERIDOS CON EL ENGANCHE SEGUN LO INDICADO. LOS VALORES ANTERIORES SON EN PESOS, MERAMENTE INFORMATIVOS, POR LO QUE ESTE DOCUMENTO NO CONSTITUYE PREAPROBACIÓN Y POR LO TANTO NO COMPROMETE AL DESARROLLADOR.\n\nMONTOS, PORCENTAJES Y PRECIOS EN LA PRESENTE COTIZACIÓN PUEDEN CAMBIAR SIN PREVIO AVISO.\n\nEL ENGANCHE DE FIRMA (2.5%) INCLUYE LA DEDUCCIÓN DEL MONTO DE APARTADO; SI EL APARTADO LO EXCEDE, EL EXCEDENTE SE ACREDITA AL ENGANCHE.\n\nLA PRESENTE COTIZACIÓN TIENE UNA VIGENCIA DE 3 DÍAS NATURALES A PARTIR DE LA FECHA DE EMISIÓN, PARA CONGELAR EL PRECIO DE VENTA ES NECESARIO REALIZAR EL PAGO DE SU APARTADO COMPLETO Y TENDRÁ 7 DÍAS PARA FORMALIZAR Y NO PERDER LA UBICACIÓN.'
const CUENTA_DEPOSITO =
'PARA CUENTAS EN MEXICO:\nTITULAR: FRASA DESARROLLOS, S.A.P.I. DE C.V.\n\nBBVA BANCOMER PAGO SOLO EN PESOS\nCUENTA: 0111525921 CLABE: 012028001115259210\n\nBBVA BANCOMER PAGO SOLO EN DOLARES\nCUENTA: 0111571737 CLABE: 012028001115717370\n\nPARA RECIBIR TRANSFERENCIAS DESDE EL EXTRANJERO:\nBANCO DEL BENEFICIARIO: BBVA Bancomer.\nSWIFT/BIC CODE: BCMRMXMMXXX\nBENEFICIARIO: FRASA DESARROLLOS, S.A.P.I. DE C.V.\nCUENTA: 01111571737\n(CLABE 18 DÍGITOS): 012 028 001 115 717 370\nSOLO PAGO EN DOLARES'
@@ -50,7 +51,7 @@ export interface DocumentoPdf {
}
/** Construye el documento PDF "ANEXO A" (sin descargarlo) */
export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, propuestas, cliente, desarrollo, folio, qrDataUrl, vigencia }: PdfInput): DocumentoPdf {
export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, esquema, cliente, desarrollo, folio, qrDataUrl, vigencia }: PdfInput): DocumentoPdf {
const params = desarrollo ?? DESARROLLOS[DESARROLLO_DEFAULT]
const doc = new jsPDF()
const hoy = new Date()
@@ -128,51 +129,55 @@ export function crearDocumentoPdf({ unidad, acabados, apartado, precioLista, pro
})
y = finalY(doc) + 8
// 3. Propuesta general — una tabla por propuesta
// 3. Esquema único FRASA 2026 — una sola tabla con la línea de tiempo
doc.setFontSize(12)
doc.setFont('helvetica', 'bold')
doc.text('PROPUESTA GENERAL', MARGEN, y)
doc.text('PROPUESTA GENERAL — ESQUEMA FRASA 2026', MARGEN, y)
y += 4
propuestas.forEach((p, i) => {
const mensualidades = p.notaMensualidades
? { pagos: `— (${p.notaMensualidades})`, total: '—' }
: p.mensualidad !== null && p.mensualidadesTotal !== null
? {
pagos: `${p.numMensualidades} × ${formatoMoneda(p.mensualidad)}`,
total: formatoMoneda(p.mensualidadesTotal),
}
: { pagos: 'N/A', total: 'N/A' }
// Con una sola propuesta (la elegida por el vendedor) no se numera
const encabezado =
propuestas.length === 1
? `PROPUESTA COMERCIAL — ${p.titulo.toUpperCase()}`
: `PROPUESTA COMERCIAL #${i + 1} — ${p.titulo.toUpperCase()}`
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 8 },
headStyles: { fillColor: [37, 99, 235] },
head: [[encabezado, '%', 'PAGOS MENSUALES', 'TOTAL']],
body: [
['DESCUENTO', formatoPorcentaje(p.descuento), '', ''],
['MONTO DE DESCUENTO', '', '', formatoMoneda(p.montoDescuento)],
['PRECIO FINAL C/DESC', '', '', formatoMoneda(p.precioFinal)],
['ENGANCHE', '', '', formatoMoneda(p.enganche)],
['MENSUALIDADES', '', mensualidades.pagos, mensualidades.total],
['PAGO FINAL', '', '', formatoMoneda(p.pagoFinal)],
autoTable(doc, {
startY: y,
margin: { left: MARGEN, right: MARGEN },
styles: { fontSize: 8 },
headStyles: { fillColor: [37, 99, 235] },
head: [['CONCEPTO', '%', 'PAGOS MENSUALES', 'TOTAL']],
body: [
['APARTADO (HOY)', '', '', formatoMoneda(apartado)],
[
`ENGANCHE DE FIRMA (${formatoPorcentaje(PCT_ENGANCHE_FIRMA)})`,
formatoPorcentaje(PCT_ENGANCHE_FIRMA),
'',
formatoMoneda(esquema.engancheFirmaTotal),
],
})
y = finalY(doc) + 6
// Salto de página antes de la siguiente tabla si no cabe
if (y > 240 && i < propuestas.length - 1) {
doc.addPage()
y = MARGEN
}
[
`SALDO A LA FIRMA (firma − apartado${esquema.excedenteApartado > 0 ? ' + excedente al enganche' : ''})`,
'',
'',
formatoMoneda(esquema.saldoFirma),
],
[
`GASTOS DE ESCRITURACIÓN (${formatoPorcentaje(PCT_ESCRITURACION)}) — ${esquema.escrituracionDiferida ? 'DIFERIDOS CON EL ENGANCHE' : 'PAGADOS A LA FIRMA'}`,
formatoPorcentaje(PCT_ESCRITURACION),
'',
formatoMoneda(esquema.escrituracion),
],
['PAGO A LA FIRMA', '', '', formatoMoneda(esquema.pagoUnicoFirma)],
[
`ENGANCHE (${formatoPorcentaje(PCT_ENGANCHE)}) DIFERIBLE`,
formatoPorcentaje(PCT_ENGANCHE),
'',
formatoMoneda(esquema.enganche),
],
[
'MENSUALIDADES',
'',
`${esquema.numMensualidades} × ${formatoMoneda(esquema.mensualidad)}`,
formatoMoneda(esquema.baseDiferible),
],
['TOTAL DEL ESQUEMA', '', '', formatoMoneda(esquema.totalPagado)],
],
})
y = finalY(doc) + 6
y += 2
// 4. ¿Qué sigue?

View File

@@ -9,7 +9,8 @@ import { INVENTARIO_SEED } from '../data/unidades'
/**
* Reglas configurables por el admin en la página del cotizador:
* - un desarrollo con activoEnCotizador: false no se ofrece
* - una propuesta desactivada no se muestra ni se puede elegir
* - una propuesta desactivada ya NO afecta: el esquema único FRASA 2026 no
* usa las 4 propuestas (sigue cotizando igual)
*/
const unidadDiagonal = INVENTARIO_SEED.find((u) => u.desarrollo === 'DIAGONAL')!
@@ -60,7 +61,7 @@ afterEach(() => {
})
describe('CotizadorPage con reglas del admin', () => {
it('no ofrece el desarrollo desactivado ni la propuesta desactivada', async () => {
it('no ofrece el desarrollo desactivado; una propuesta desactivada ya no bloquea el esquema', async () => {
render(
<MemoryRouter>
<CotizadorPage />
@@ -74,16 +75,18 @@ describe('CotizadorPage con reglas del admin', () => {
expect(screen.getByRole('button', { name: /Diagonal/ })).toBeTruthy()
expect(screen.getByRole('button', { name: /Senda/ })).toBeTruthy()
// Cargar las 4 propuestas de DIAGONAL: solo las 3 vigentes
// Cargar la unidad de DIAGONAL (con la propuesta Contado desactivada por
// el admin): el esquema único se calcula igual, sin propuestas
fireEvent.focus(inputUnidad)
fireEvent.change(inputUnidad, { target: { value: unidadDiagonal.codigo } })
fireEvent.mouseDown(screen.getByRole('option', { name: new RegExp(unidadDiagonal.codigo) }))
await waitFor(() => {
expect(screen.getByText('Crédito Bancario')).toBeTruthy()
expect(screen.getByText('Esquema FRASA 2026 — conceptos')).toBeTruthy()
})
expect(screen.getByText('Crédito Infonavit-Cofinavit')).toBeTruthy()
expect(screen.getByText('Semi Contado')).toBeTruthy()
expect(screen.queryByText('Contado')).toBeNull()
// La línea de tiempo del esquema (Hoy / A la firma / Después)
expect(screen.getByText('Hoy')).toBeTruthy()
expect(screen.getByText('A la firma')).toBeTruthy()
expect(screen.getByText('Después')).toBeTruthy()
})
})

View File

@@ -6,9 +6,9 @@ import CotizadorPage from './CotizadorPage'
import { INVENTARIO_SEED } from '../data/unidades'
/**
* Smoke del cierre de la cotización:
* - barra sticky con propuesta elegida + Compartir/Guardar/Generar PDF
* - preselección por defecto de la propuesta de menor total
* Smoke del cierre de la cotización con el esquema único FRASA 2026:
* - barra sticky con resumen del esquema + Compartir/Guardar/Generar PDF
* - panel único (línea de tiempo Hoy → Firma → Mensualidades)
* - sin servidor: la cotización se guarda localmente con folio CTE-LOCAL-*
*/
const unidad = INVENTARIO_SEED.find((u) => u.status === 'DISPONIBLE' && u.desarrollo === 'DIAGONAL')!
@@ -35,7 +35,7 @@ afterEach(() => {
})
describe('CotizadorPage — barra sticky y guardado local', () => {
it('preselecciona la propuesta más barata y guarda local con folio CTE-LOCAL cuando el API no responde', async () => {
it('muestra el esquema único y guarda local con folio CTE-LOCAL cuando el API no responde', async () => {
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input)
const metodo = init?.method ?? 'GET'
@@ -58,7 +58,7 @@ describe('CotizadorPage — barra sticky y guardado local', () => {
expect(await screen.findByRole('button', { name: 'Generar PDF' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Guardar' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Compartir' })).toBeTruthy()
expect(screen.getByText(/Selecciona una unidad para elegir propuesta/)).toBeTruthy()
expect(screen.getByText(/Selecciona una unidad para ver el esquema de cotización\./)).toBeTruthy()
// Elegir una unidad del inventario
const inputUnidad = screen.getByLabelText('Unidad') as HTMLInputElement
@@ -66,12 +66,11 @@ describe('CotizadorPage — barra sticky y guardado local', () => {
fireEvent.change(inputUnidad, { target: { value: unidad.codigo } })
fireEvent.mouseDown(screen.getByRole('option', { name: new RegExp(unidad.codigo) }))
// Preselección por defecto: la propuesta de menor total pagado
const selectPropuesta = (await screen.findByLabelText(
'Cambiar propuesta elegida',
)) as HTMLSelectElement
await waitFor(() => expect(selectPropuesta.value).toBe('2')) // Contado es la más barata
expect(screen.getByText(/Propuesta elegida: #3/)).toBeTruthy()
// Panel único del esquema: línea de tiempo y conceptos
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()
// Guardar con el servidor caído → folio local persistido en el dispositivo
fireEvent.change(screen.getByLabelText(/Nombre del cliente/), {
@@ -82,10 +81,20 @@ describe('CotizadorPage — barra sticky y guardado local', () => {
await waitFor(() => {
const locales = JSON.parse(
almacenamiento.get('frasa-cotizador:cotizaciones-locales') ?? '[]',
) as { folio?: string; clienteNombre?: string }[]
) as {
folio?: string
clienteNombre?: string
esquema?: string
tipoPropuesta?: string
saldoFirma?: number | null
}[]
expect(locales).toHaveLength(1)
expect(locales[0]?.folio ?? '').toMatch(/^CTE-LOCAL-\d+$/)
expect(locales[0]?.clienteNombre).toBe('Cliente Prueba Local')
// La instantánea local ya vive en el esquema único
expect(locales[0]?.esquema).toBe('FRASA_2026')
expect(locales[0]?.tipoPropuesta).toBe('FRASA_2026')
expect(locales[0]?.saldoFirma).not.toBeNull()
})
expect(screen.getByText(/cotización guardada localmente con folio CTE-LOCAL-/)).toBeTruthy()
})
@@ -100,12 +109,17 @@ describe('CotizadorPage — barra sticky y guardado local', () => {
clienteNombre: 'Cliente Antiguo',
clienteTelefono: null,
clienteEmail: null,
esquema: 'FRASA_2026',
tipoPropuesta: 'BANCARIO',
acabados: false,
apartado: 20000,
descuento: 0,
precioLista: vendida.precioConAcabados,
precioFinal: 0,
saldoFirma: null,
engancheDiferible: null,
escrituracion: null,
escrituracionDiferida: false,
enganche: 0,
numMensualidades: 12,
mensualidad: 1000,
@@ -139,6 +153,8 @@ describe('CotizadorPage — barra sticky y guardado local', () => {
const alerta = await screen.findByRole('alert')
expect(alerta.textContent).toContain(`La unidad ${vendida.codigo} no está disponible`)
expect(screen.getByText(/ya no está disponible \(estatus: VENDIDO\): no se puede cotizar de nuevo/)).toBeTruthy()
// Es una cotización del esquema anterior (legacy): lo dice al retomarla
expect(screen.getByText(/Usa el esquema anterior \(BANCARIO\)/)).toBeTruthy()
for (const nombre of ['Generar PDF', 'Guardar', 'Compartir']) {
expect((screen.getByRole('button', { name: nombre }) as HTMLButtonElement).disabled).toBe(true)
}

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 && (

View File

@@ -1,4 +1,4 @@
import type { Unidad, TipoPropuesta } from '../types'
import type { Unidad } from '../types'
import { INVENTARIO_SEED } from '../data/unidades'
import { reglasDesdeApi, type ReglasDesarrollo } from '../lib/reglas'
import { conAuth, manejar401 } from './auth'
@@ -72,22 +72,12 @@ export async function getReglasCotizador(): Promise<ReglasDesarrollo> {
// ---------- Cotizaciones guardadas (historial del vendedor) ----------
/** Tipo de propuesta tal como lo espera el API (body de POST /cotizaciones) */
export type TipoPropuestaApi = 'BANCARIO' | 'INFONAVIT' | 'CONTADO' | 'SEMI_CONTADO'
export const TIPO_A_API: Record<TipoPropuesta, TipoPropuestaApi> = {
CREDITO_BANCARIO: 'BANCARIO',
CREDITO_INFONAVIT_COFINAVIT: 'INFONAVIT',
CONTADO: 'CONTADO',
SEMI_CONTADO: 'SEMI_CONTADO',
}
export const TIPO_DE_API: Record<TipoPropuestaApi, TipoPropuesta> = {
BANCARIO: 'CREDITO_BANCARIO',
INFONAVIT: 'CREDITO_INFONAVIT_COFINAVIT',
CONTADO: 'CONTADO',
SEMI_CONTADO: 'SEMI_CONTADO',
}
/**
* Tipo de propuesta tal como viene del API. Las cotizaciones nuevas usan el
* esquema único FRASA_2026; los valores legacy (BANCARIO, …) quedan en las
* filas viejas para no romper reportes ni el historial.
*/
export type TipoPropuestaApi = 'BANCARIO' | 'INFONAVIT' | 'CONTADO' | 'SEMI_CONTADO' | 'FRASA_2026'
export interface CotizacionGuardada {
id: string
@@ -97,12 +87,22 @@ export interface CotizacionGuardada {
clienteNombre: string
clienteTelefono: string | null
clienteEmail: string | null
/** Esquema único FRASA 2026 (las viejas traen su tipoPropuesta legacy) */
esquema?: string
tipoPropuesta: TipoPropuestaApi
acabados: boolean
apartado: number
descuento: number
precioLista: number
precioFinal: number
/** Lo que se paga a la firma por el enganche de firma (esquema nuevo) */
saldoFirma?: number | null
/** Enganche del 7.5%, neto del excedente del apartado (esquema nuevo) */
engancheDiferible?: number | null
/** Gastos de escrituración 5.5% (esquema nuevo) */
escrituracion?: number | null
/** true = escrituración diferida con el enganche (esquema nuevo) */
escrituracionDiferida?: boolean
enganche: number
numMensualidades: number
mensualidad: number | null
@@ -115,12 +115,13 @@ export interface GuardarCotizacionPayload {
clienteNombre: string
clienteEmail?: string
clienteTelefono?: string
tipoPropuesta: TipoPropuestaApi
acabados: boolean
/** Apartado: cantidad libre (sin él, el API usa su default) */
apartado?: number
descuento?: number
/** Mensualidades elegidas por el vendedor (sin él, el API usa el plazo del desarrollo) */
/** Mensualidades del diferimiento (1..36; sin ellas, el API usa 14) */
numMensualidades?: number
/** true = la escrituración (5.5%) se difiere con el enganche */
escrituracionDiferida?: boolean
/** Item del tablero Oportunidades a vincular (botón "Cotizar" de Mi día) */
oportunidadId?: string
}