feat(cotizador): multi-desarrollo — ATELIER, ESTANZA, SENDA, SOLANA + DIAGONAL

- Motor de cálculo parametrizado por desarrollo (enganches, descuentos, splits,
  fuente de N, quirk Infonavit-lista); DIAGONAL byte-idéntico
- 285 unidades (5 desarrollos) en seeds, API (?desarrollo=) y board Monday
- Selector de desarrollo en UI, propuestas por config, PDF multi-desarrollo
- Knowledge IA: catálogo por desarrollo + matriz de diferencias (10 docs)
- Tests: cotizador 53, api 99 — fixtures de los Excel validados al centavo
This commit is contained in:
2026-08-22 04:31:21 +00:00
parent 05c44478ec
commit afb454b54c
33 changed files with 2074 additions and 338 deletions

View File

@@ -10,6 +10,7 @@ import { ComparadorPropuestas } from '../components/ComparadorPropuestas'
import { generarPdf } from '../lib/pdf'
import { formatoMoneda, formatoFechaLarga } from '../lib/formatters'
import { calcularVigencia } from '../lib/vigencia'
import { LISTA_DESARROLLOS, obtenerDesarrollo } from '../data/desarrollos'
import type { Cliente } from '../types'
const esquemaCliente = z.object({
@@ -22,13 +23,6 @@ const esquemaCliente = z.object({
type FormularioCliente = z.infer<typeof esquemaCliente>
const TITULOS_PROPUESTAS: [string, keyof import('../types').DescuentosPropuestas][] = [
['Crédito Bancario', 'creditoBancario'],
['Crédito Infonavit-Cofinavit', 'creditoInfonavit'],
['Contado', 'contado'],
['Semi Contado', 'semiContado'],
]
const campoInputBase =
'w-full border bg-white px-3 py-2.5 text-sm transition-colors duration-150 focus:border-neutral-900 focus:outline-none'
const campoInput = `${campoInputBase} border-neutral-300`
@@ -40,6 +34,9 @@ export default function CotizadorPage() {
const { unidades, cargando, error, usandoSeed } = useUnidades()
const cotizacion = useCotizacion()
const {
desarrollo,
setDesarrollo,
params,
unidad,
setUnidad,
acabados,
@@ -52,6 +49,9 @@ export default function CotizadorPage() {
propuestas,
} = cotizacion
const unidadesDesarrollo = unidades.filter((u) => (u.desarrollo ?? 'DIAGONAL') === desarrollo)
const disponiblesDesarrollo = unidadesDesarrollo.filter((u) => u.status === 'DISPONIBLE').length
const {
register,
watch,
@@ -72,7 +72,7 @@ export default function CotizadorPage() {
email: watch('email') || undefined,
telefono: watch('telefono') || undefined,
}
generarPdf({ unidad, acabados, apartado, precioLista, propuestas, cliente })
generarPdf({ unidad, acabados, apartado, precioLista, propuestas, cliente, desarrollo: params })
}
return (
@@ -105,15 +105,51 @@ export default function CotizadorPage() {
{cargando ? (
<p className="text-sm text-neutral-500">Cargando unidades…</p>
) : (
<UnitSelector
unidades={unidades}
valor={unidad?.codigo ?? ''}
onChange={(codigo) =>
setUnidad(unidades.find((u) => u.codigo === codigo) ?? null)
}
/>
<>
<div>
<span className={etiquetaCampo}>Desarrollo</span>
<div className="flex flex-wrap border border-neutral-300" role="group">
{LISTA_DESARROLLOS.map((id) => {
const activo = id === desarrollo
const total = unidades.filter((u) => (u.desarrollo ?? 'DIAGONAL') === id).length
const disponibles = unidades.filter(
(u) => (u.desarrollo ?? 'DIAGONAL') === id && u.status === 'DISPONIBLE',
).length
return (
<button
key={id}
type="button"
aria-pressed={activo}
onClick={() => setDesarrollo(id)}
className={`px-4 py-2.5 text-xs font-bold tracking-widest uppercase transition-colors duration-150 ${
activo
? 'bg-accent text-white'
: 'bg-white text-neutral-600 hover:bg-neutral-100'
}`}
>
{obtenerDesarrollo(id).nombre}
<span className={`ml-1 font-normal ${activo ? 'text-white' : 'text-neutral-400'}`}>
{disponibles}/{total}
</span>
</button>
)
})}
</div>
<p className="mt-2 text-xs text-neutral-500">
{unidadesDesarrollo.length} unidades · {disponiblesDesarrollo} disponibles
</p>
</div>
<UnitSelector
unidades={unidadesDesarrollo}
valor={unidad?.codigo ?? ''}
onChange={(codigo) =>
setUnidad(unidadesDesarrollo.find((u) => u.codigo === codigo) ?? null)
}
/>
</>
)}
{!params.precioUnico && (
<div>
<span className={etiquetaCampo}>Cocina y closet</span>
<div className="inline-flex border border-neutral-300" role="group">
@@ -137,6 +173,7 @@ export default function CotizadorPage() {
})}
</div>
</div>
)}
<div>
<label htmlFor="apartado" className={etiquetaCampo}>
@@ -227,9 +264,9 @@ export default function CotizadorPage() {
<ProposalCard
key={p.tipo}
numero={i + 1}
titulo={TITULOS_PROPUESTAS[i][0]}
titulo={p.titulo}
propuesta={p}
onDescuentoChange={(v) => setDescuento(TITULOS_PROPUESTAS[i][1], v)}
onDescuentoChange={(v) => setDescuento(params.propuestas[i].claveDescuento, v)}
/>
))}
</div>
@@ -237,7 +274,7 @@ export default function CotizadorPage() {
precioLista={precioLista}
apartado={apartado}
propuestas={propuestas}
titulos={TITULOS_PROPUESTAS.map(([titulo]) => titulo)}
titulos={propuestas.map((p) => p.titulo)}
/>
<div className="mt-16 flex justify-end">
<button