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

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

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

View File

@@ -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>
)
}