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,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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
164
apps/cotizador/src/components/EsquemaPanel.tsx
Normal file
164
apps/cotizador/src/components/EsquemaPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
81
apps/cotizador/src/lib/esquema-frasa.test.ts
Normal file
81
apps/cotizador/src/lib/esquema-frasa.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
100
apps/cotizador/src/lib/esquema-frasa.ts
Normal file
100
apps/cotizador/src/lib/esquema-frasa.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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)}`)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user