feat(desarrollos): modulo de alta de desarrollos con inventario, reglas del cotizador por desarrollo y micrositio publico

Modulo nuevo (solo admin) para dar de alta desarrollos sin tocar codigo:
- CRUD de la ficha, modelos e inventario (alta manual y generador por rango)
- Reglas del cotizador por desarrollo, editables y persistidas en la BD
- El cotizador consume las reglas desde /desarrollos/reglas-cotizador y cae a
  las constantes con aviso cuando un desarrollo aun no tiene reglas guardadas
- Micrositio publico en /d/:clave (sin sesion) con ficha, modelos y
  disponibilidad; jamas expone datos de clientes ni cotizaciones

Integracion:
- La landing monta el modulo en el grid (clave 'desarrollos', solo admin) y el
  micrositio fuera del login; el rol manager NO accede (matriz y API con 403)
- La tabla de roles de Configuracion ahora lista todos los modulos
- Se alinea la FK de ReglaCotizadorDesarrollo con la BD viva (Desarrollo.id)

Verificado: 708 pruebas del API, 115 del cotizador y 89 de la landing en verde;
12/12 cotizaciones identicas antes y despues del cambio de reglas; prueba en
vivo del API con alta, modelos, inventario, reglas, micrositio y baja (30/30).
This commit is contained in:
2026-09-21 09:52:47 +00:00
parent 2fc80e7f9e
commit d6548999bf
68 changed files with 10016 additions and 66 deletions

View File

@@ -22,7 +22,7 @@ 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 { LISTA_DESARROLLOS, obtenerDesarrollo } from '../data/desarrollos'
import { desarrolloEfectivo, idsCotizables, propuestaVigente } from '../lib/reglas'
import {
getMisCotizaciones,
guardarCotizacion,
@@ -56,8 +56,8 @@ const campoInputError = `${campoInputBase} border-red-400`
const etiquetaCampo = 'mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
export default function CotizadorPage() {
const { unidades, cargando, error, usandoSeed } = useUnidades()
const cotizacion = useCotizacion()
const { unidades, cargando, error, usandoSeed, reglas } = useUnidades()
const cotizacion = useCotizacion(reglas)
const {
desarrollo,
setDesarrollo,
@@ -79,10 +79,13 @@ export default function CotizadorPage() {
setNumMensualidades,
numMeses,
propuestas,
avisoPropuestaDesactivada,
restaurar,
nueva,
} = cotizacion
/** Desarrollos que se ofrecen: los activos en las reglas del admin */
const desarrollos = idsCotizables(reglas)
const unidadesDesarrollo = unidades.filter((u) => (u.desarrollo ?? 'DIAGONAL') === desarrollo)
const disponiblesDesarrollo = unidadesDesarrollo.filter((u) => u.status === 'DISPONIBLE').length
@@ -101,6 +104,24 @@ export default function CotizadorPage() {
const [oportunidadId, setOportunidadId] = useState<string | null>(null)
const [unidadPendiente, setUnidadPendiente] = useState<string | null>(null)
// Si el desarrollo activo deja de ofrecerse (lo desactivó el admin), se cae al
// primero disponible para no dejar el cotizador en un desarrollo muerto.
useEffect(() => {
if (desarrollos.includes(desarrollo)) return
const alterno = desarrollos[0]
if (alterno === undefined) return
setDesarrollo(alterno)
setAviso(
`El desarrollo ${desarrollo} ya no está disponible en el cotizador; se cambió a ${alterno}.`,
)
// 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 */
@@ -159,7 +180,13 @@ export default function CotizadorPage() {
if (cliente) setValue('nombre', cliente, { shouldValidate: true })
if (telefono) setValue('telefono', telefono, { shouldValidate: true })
if (email) setValue('email', email, { shouldValidate: true })
if (desarrolloParam && LISTA_DESARROLLOS.includes(desarrolloParam)) setDesarrollo(desarrolloParam)
if (desarrolloParam) {
if (desarrollos.includes(desarrolloParam)) setDesarrollo(desarrolloParam)
else
setAviso(
`El desarrollo ${desarrolloParam} no está disponible en el cotizador. Elige un desarrollo de la lista.`,
)
}
if (unidadParam) setUnidadPendiente(unidadParam)
if (opId) setOportunidadId(opId)
// Solo al montar: es el punto de entrada desde Mi día
@@ -180,6 +207,10 @@ export default function CotizadorPage() {
setAviso(
`La unidad ${encontrada.codigo} ya no está disponible (estatus: ${encontrada.status}). Elige otra unidad disponible.`,
)
} else if (!desarrollos.includes(encontrada.desarrollo ?? 'DIAGONAL')) {
setAviso(
`El desarrollo de la unidad ${encontrada.codigo} no está disponible en el cotizador: elige otra unidad disponible.`,
)
} else {
const desarrolloUnidad = encontrada.desarrollo ?? 'DIAGONAL'
if (desarrolloUnidad !== desarrollo) setDesarrollo(desarrolloUnidad)
@@ -418,7 +449,13 @@ export default function CotizadorPage() {
return
}
const tipo = TIPO_DE_API[c.tipoPropuesta]
const idx = obtenerDesarrollo(c.desarrollo).propuestas.findIndex((p) => p.tipo === tipo)
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
restaurar({
desarrollo: c.desarrollo,
@@ -498,7 +535,7 @@ export default function CotizadorPage() {
<div>
<span className={etiquetaCampo}>Desarrollo</span>
<div className="flex flex-wrap overflow-hidden rounded-lg border border-[#D1D5DB]" role="group">
{LISTA_DESARROLLOS.map((id) => {
{desarrollos.map((id) => {
const activo = id === desarrollo
const total = unidades.filter((u) => (u.desarrollo ?? 'DIAGONAL') === id).length
const disponibles = unidades.filter(
@@ -516,7 +553,7 @@ export default function CotizadorPage() {
: 'bg-white text-[#4B5563] dark:text-[#CBD5E1] hover:bg-[#F9FAFB] dark:bg-[#0F172A] dark:hover:bg-[#1E293B]'
}`}
>
{obtenerDesarrollo(id).nombre}
{desarrolloEfectivo(reglas, id).nombre}
<span className={`ml-1 font-normal ${activo ? 'text-blue-100' : 'text-[#64748B] dark:text-[#94A3B8]'}`}>
{disponibles}/{total}
</span>
@@ -649,7 +686,9 @@ export default function CotizadorPage() {
if (lead.telefono) setValue('telefono', lead.telefono, { shouldValidate: true })
if (lead.email) setValue('email', lead.email, { shouldValidate: true })
if (lead.fuente === 'oportunidad') setOportunidadId(lead.id)
if (lead.desarrollo && LISTA_DESARROLLOS.includes(lead.desarrollo)) setDesarrollo(lead.desarrollo)
if (lead.desarrollo && desarrollos.includes(lead.desarrollo)) {
setDesarrollo(lead.desarrollo)
}
setAviso(
`Datos de ${lead.nombre} cargados${
lead.fuente === 'oportunidad' ? ' — la cotización quedará vinculada a su oportunidad' : ''
@@ -768,6 +807,7 @@ export default function CotizadorPage() {
tipo={propuestaActiva?.tipo ?? 'CREDITO_BANCARIO'}
apartado={apartadoEfectivo}
acabados={acabados}
reglas={reglas}
/>
</div>
</main>