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

@@ -15,6 +15,7 @@
"seed:ai": "tsx scripts/seed-ai.ts",
"seed:usuarios": "tsx scripts/seed-usuarios.ts",
"seed:config": "tsx scripts/seed-config.js",
"seed:desarrollos": "tsx scripts/seed-desarrollos.js",
"seed:unidades": "tsx scripts/seed-unidades-locales.js",
"prisma:migrate": "prisma migrate dev",
"seed:catalogos": "node scripts/actualizar-catalogos-ia.js"

View File

@@ -0,0 +1,58 @@
-- Módulo Desarrollos: catálogo de desarrollos (ficha, modelos y reglas del
-- cotizador) más el piso (`nivel`) del inventario.
-- Patrón manual: sqlite3 + _prisma_migrations. NUNCA migrate dev.
-- CreateTable Desarrollo
CREATE TABLE IF NOT EXISTS "Desarrollo" (
"id" TEXT NOT NULL PRIMARY KEY,
"clave" TEXT NOT NULL,
"nombre" TEXT NOT NULL,
"estado" TEXT NOT NULL DEFAULT 'ACTIVO',
"tipo" TEXT NOT NULL DEFAULT 'VERTICAL',
"constructora" TEXT,
"direccion" TEXT,
"zona" TEXT,
"descripcion" TEXT,
"amenidades" TEXT,
"fechaInicioObra" DATETIME,
"fechaEntregaProgramada" DATETIME,
"fechaEntregaReprogramada" DATETIME,
"activoEnCotizador" BOOLEAN NOT NULL DEFAULT true,
"activoEnAlta" BOOLEAN NOT NULL DEFAULT true,
"prefijoUnidadPdf" TEXT,
"imagenUrl" TEXT,
"logoUrl" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS "Desarrollo_clave_key" ON "Desarrollo"("clave");
CREATE INDEX IF NOT EXISTS "Desarrollo_estado_idx" ON "Desarrollo"("estado");
-- CreateTable ModeloDesarrollo
CREATE TABLE IF NOT EXISTS "ModeloDesarrollo" (
"id" TEXT NOT NULL PRIMARY KEY,
"desarrolloId" TEXT NOT NULL,
"nombre" TEXT NOT NULL,
"recamaras" INTEGER,
"banos" REAL,
"estacionamientos" INTEGER,
"supConstruccion" REAL,
"activo" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "ModeloDesarrollo_desarrolloId_fkey" FOREIGN KEY ("desarrolloId") REFERENCES "Desarrollo" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "ModeloDesarrollo_desarrolloId_nombre_key" ON "ModeloDesarrollo"("desarrolloId", "nombre");
CREATE INDEX IF NOT EXISTS "ModeloDesarrollo_desarrolloId_idx" ON "ModeloDesarrollo"("desarrolloId");
-- CreateTable ReglaCotizadorDesarrollo
-- `desarrolloId` guarda el uuid de `Desarrollo.id` (lo que escriben el seed y
-- PUT /desarrollos/:id/reglas). El endpoint del cotizador traduce uuid → clave.
CREATE TABLE IF NOT EXISTS "ReglaCotizadorDesarrollo" (
"desarrolloId" TEXT NOT NULL PRIMARY KEY,
"parametrosJson" TEXT NOT NULL,
"updatedAt" DATETIME NOT NULL,
"updatedPorId" TEXT,
CONSTRAINT "ReglaCotizadorDesarrollo_desarrolloId_fkey" FOREIGN KEY ("desarrolloId") REFERENCES "Desarrollo" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- AlterTable Unidad: piso del inventario ('' si no aplica)
ALTER TABLE "Unidad" ADD COLUMN "nivel" TEXT NOT NULL DEFAULT '';

View File

@@ -211,6 +211,7 @@ model Unidad {
modelo String @default("")
manzana String @default("")
lote String @default("")
nivel String @default("") // piso (desarrollos verticales); '' si no aplica
supConstruccion Float @default(0)
supExcedente Float @default(0)
supEstacionamiento Float @default(0)
@@ -530,3 +531,61 @@ model SolicitudSoporte {
@@index([usuarioId])
@@index([departamento])
}
// ── Módulo Desarrollos: catálogo de desarrollos nuevos ────────────────
// La BD es la fuente de verdad: la ficha del desarrollo, sus modelos y las
// reglas del cotizador (antes constantes fijas en el código de las apps).
model Desarrollo {
id String @id @default(uuid())
clave String @unique // SENDA, DIAGONAL… (forma los códigos de unidad)
nombre String // nombre comercial
estado String @default("ACTIVO") // ACTIVO | PREVENTA | CERRADO
tipo String @default("VERTICAL") // VERTICAL | HORIZONTAL
constructora String?
direccion String?
zona String?
descripcion String?
amenidades String? // texto libre o lista separada por comas
fechaInicioObra DateTime?
fechaEntregaProgramada DateTime?
fechaEntregaReprogramada DateTime?
activoEnCotizador Boolean @default(true) // aparece en el cotizador
activoEnAlta Boolean @default(true) // aparece en altas de lead/afluencia/cliente
prefijoUnidadPdf String? // p. ej. CASA (lo usa el PDF del cotizador)
imagenUrl String?
logoUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
modelos ModeloDesarrollo[]
reglas ReglaCotizadorDesarrollo?
@@index([estado])
}
model ModeloDesarrollo {
id String @id @default(uuid())
desarrolloId String
desarrollo Desarrollo @relation(fields: [desarrolloId], references: [id], onDelete: Cascade)
nombre String
recamaras Int?
banos Float?
estacionamientos Int?
supConstruccion Float?
activo Boolean @default(true)
@@unique([desarrolloId, nombre])
@@index([desarrolloId])
}
model ReglaCotizadorDesarrollo {
// `desarrolloId` guarda el uuid de `Desarrollo.id` (es lo que escriben el seed
// y PUT /desarrollos/:id/reglas). El endpoint que consume el cotizador
// (`/desarrollos/reglas-cotizador`) traduce uuid → clave antes de responder.
desarrolloId String @id
desarrollo Desarrollo @relation(fields: [desarrolloId], references: [id], onDelete: Cascade)
parametrosJson String // JSON con la forma ParametrosDesarrollo
updatedAt DateTime @updatedAt
updatedPorId String?
}

View File

@@ -0,0 +1,157 @@
/**
* Siembra el catálogo `Desarrollo` + `ModeloDesarrollo` + `ReglaCotizadorDesarrollo`
* desde la realidad actual del sistema:
*
* - Desarrollos y modelos: lo que hoy hay en la tabla `Unidad` (espejo del
* inventario), más SOLE y MUTUO, que existen en captura y altas pero todavía
* no tienen inventario local.
* - Reglas del cotizador: copiadas (replicadas, no importadas) de las
* constantes de apps/cotizador/src/data/desarrollos.ts, que es la config con
* la que hoy cotiza producción.
*
* Idempotente: upsert por `clave`. NUNCA pisa lo ya editado: si la ficha o las
* reglas existen, el seed no las toca (el admin manda).
*
* Uso: npm run seed:desarrollos (tsx: la fuente de las reglas es TypeScript)
*/
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import dotenv from 'dotenv'
import { PrismaClient } from '@prisma/client'
import { DESARROLLOS as PARAMETROS_COTIZADOR } from '../../apps/cotizador/src/data/desarrollos.ts'
const raizApi = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
dotenv.config({ path: [path.resolve(raizApi, '.env'), path.resolve(raizApi, '..', '.env')] })
const prisma = new PrismaClient()
/**
* Desarrollos sin inventario local que sí existen en el ecosistema (captura y
* altas de lead/afluencia/cliente). Los modelos de SOLE vienen de la lista
* real del alta de clientes (apps/alta-clientes/src/data/constantes.ts);
* MUTUO captura el modelo en texto libre, así que se siembra sin modelos.
*/
const SIN_INVENTARIO = [
{ clave: 'SOLE', nombre: 'Sole', tipo: 'VERTICAL', modelos: ['ALBA', 'HORIZONTE', 'OCASO'] },
{ clave: 'MUTUO', nombre: 'Mutuo', tipo: 'VERTICAL', modelos: [] },
]
const normalizar = (v) => String(v ?? '').trim().toUpperCase()
/** Nombre comercial: el del cotizador si existe; si no, la clave capitalizada */
function nombreComercial(clave) {
const delCotizador = PARAMETROS_COTIZADOR[clave]?.nombre
if (typeof delCotizador === 'string' && delCotizador !== '') return delCotizador
return clave.charAt(0) + clave.slice(1).toLowerCase()
}
async function main() {
const [porDesarrollo, modelosInventario] = await Promise.all([
prisma.unidad.groupBy({ by: ['desarrollo'], _count: { codigo: true } }),
prisma.unidad.groupBy({ by: ['desarrollo', 'modelo'], _count: { codigo: true } }),
])
if (porDesarrollo.length === 0) {
throw new Error('La tabla Unidad está vacía: corre primero `npm run seed:unidades`')
}
const claves = new Set(porDesarrollo.map((d) => normalizar(d.desarrollo)))
for (const extra of SIN_INVENTARIO) claves.add(extra.clave)
const modelosPorClave = new Map()
for (const fila of modelosInventario) {
const clave = normalizar(fila.desarrollo)
const modelo = String(fila.modelo ?? '').trim()
if (modelo === '') continue
if (!modelosPorClave.has(clave)) modelosPorClave.set(clave, new Set())
modelosPorClave.get(clave).add(modelo)
}
for (const extra of SIN_INVENTARIO) {
if (!modelosPorClave.has(extra.clave)) modelosPorClave.set(extra.clave, new Set())
for (const modelo of extra.modelos) modelosPorClave.get(extra.clave).add(modelo)
}
let fichasNuevas = 0
let fichasExistentes = 0
let modelosNuevos = 0
let reglasNuevas = 0
for (const clave of [...claves].sort()) {
const extra = SIN_INVENTARIO.find((e) => e.clave === clave)
const parametros = PARAMETROS_COTIZADOR[clave]
const previa = await prisma.desarrollo.findUnique({ where: { clave }, select: { id: true } })
const ficha = await prisma.desarrollo.upsert({
where: { clave },
// No pisa lo ya editado por el admin: si existe, se deja como está
update: {},
create: {
clave,
nombre: extra?.nombre ?? nombreComercial(clave),
// `tipo` no se deduce del inventario actual (los códigos y el prefijo
// del PDF no lo distinguen): queda el default VERTICAL y el admin lo
// ajusta desde el módulo.
tipo: extra?.tipo ?? 'VERTICAL',
prefijoUnidadPdf: typeof parametros?.prefijoUnidadPdf === 'string' ? parametros.prefijoUnidadPdf : null,
},
})
if (previa) fichasExistentes++
else fichasNuevas++
for (const nombre of [...(modelosPorClave.get(clave) ?? [])].sort()) {
const modeloPrevio = await prisma.modeloDesarrollo.findUnique({
where: { desarrolloId_nombre: { desarrolloId: ficha.id, nombre } },
select: { id: true },
})
await prisma.modeloDesarrollo.upsert({
where: { desarrolloId_nombre: { desarrolloId: ficha.id, nombre } },
update: {},
create: { desarrolloId: ficha.id, nombre },
})
if (!modeloPrevio) modelosNuevos++
}
if (parametros) {
const reglaPrevia = await prisma.reglaCotizadorDesarrollo.findUnique({
where: { desarrolloId: ficha.id },
select: { desarrolloId: true },
})
if (!reglaPrevia) {
await prisma.reglaCotizadorDesarrollo.create({
data: { desarrolloId: ficha.id, parametrosJson: JSON.stringify(parametros) },
})
reglasNuevas++
}
}
}
const total = await prisma.desarrollo.count()
const conReglas = await prisma.reglaCotizadorDesarrollo.count()
const totalModelos = await prisma.modeloDesarrollo.count()
// eslint-disable-next-line no-console
console.log(
`[seed-desarrollos] fichas: ${fichasNuevas} nueva(s), ${fichasExistentes} ya existían; ` +
`${modelosNuevos} modelo(s) nuevo(s); ${reglasNuevas} regla(s) de cotizador copiada(s)`,
)
// eslint-disable-next-line no-console
console.log(`[seed-desarrollos] total en BD: ${total} desarrollos, ${totalModelos} modelos, ${conReglas} con reglas`)
for (const d of await prisma.desarrollo.findMany({
orderBy: { clave: 'asc' },
include: { modelos: { orderBy: { nombre: 'asc' } }, reglas: { select: { desarrolloId: true } } },
})) {
// eslint-disable-next-line no-console
console.log(
` ${d.clave} (${d.estado}/${d.tipo}) — ${d.modelos.length} modelo(s)` +
`${d.reglas ? '' : ' — SIN reglas de cotizador'}: ${d.modelos.map((m) => m.nombre).join(', ') || '—'}`,
)
}
}
main()
.catch((err) => {
console.error('[seed-desarrollos] error:', err)
process.exitCode = 1
})
.finally(() => prisma.$disconnect())

View File

@@ -15,6 +15,7 @@ export const MODULOS = [
'clientes-maestro',
'captura',
'soporte',
'desarrollos',
] as const
export type Modulo = (typeof MODULOS)[number]
@@ -50,7 +51,8 @@ export const MATRIZ: Record<Rol, readonly Modulo[]> = {
escrituracion: [...MODULOS_VENDEDOR, 'cobranza'],
direccion_general: ['mi-dia', 'tablero-comercial', 'cobranza', 'chat', 'clientes-maestro', 'captura', 'soporte'],
// manager: mismos módulos que admin, sin poder crear usuarios ni editar permisos
manager: MODULOS,
// ni dar de alta desarrollos (módulo exclusivo del admin)
manager: MODULOS.filter((m) => m !== 'desarrollos'),
admin: MODULOS,
}

View File

@@ -33,12 +33,17 @@ const prismaMock = vi.hoisted(() => ({
auditoriaLog: {
create: vi.fn().mockResolvedValue({}),
},
// Reglas comerciales configurables por el admin (vacías = constantes)
reglaCotizadorDesarrollo: {
findMany: vi.fn(),
},
$transaction: vi.fn(),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
import { alertasInventarioEndpoint, crearCotizacion } from './cotizaciones.controller.js'
import { DESARROLLOS } from '../services/cotizacion.service.js'
/**
* Cotizaciones 100% locales: la unidad sale del espejo (tabla Unidad), la
@@ -91,6 +96,7 @@ beforeEach(() => {
prismaMock.actividadLocal.create.mockImplementation(async ({ data }) => ({ id: 'act-1', ...data }))
prismaMock.automatizacionState.findUnique.mockResolvedValue(null)
prismaMock.automatizacionState.upsert.mockResolvedValue({})
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue([])
prismaMock.$transaction.mockImplementation(async (ops: Promise<unknown>[]) => Promise.all(ops))
})
@@ -233,6 +239,107 @@ describe('crearCotizacion (POST /api/cotizaciones) — local', () => {
})
})
describe('crearCotizacion — reglas comerciales del admin', () => {
/** Reglas guardadas del desarrollo, con los ajustes del test */
function reglasGuardadas(
desarrollo: string,
ajustes: { propuestaInactiva?: string; activoEnCotizador?: boolean; descuentoContado?: number } = {},
) {
const base = DESARROLLOS[desarrollo]!
return [
{
desarrolloId: desarrollo,
parametrosJson: JSON.stringify({
...base,
...(ajustes.activoEnCotizador !== undefined
? { activoEnCotizador: ajustes.activoEnCotizador }
: {}),
propuestas: base.propuestas.map((p) => ({
...p,
activa: p.tipo !== ajustes.propuestaInactiva,
...(p.tipo === 'CONTADO' && ajustes.descuentoContado !== undefined
? { descuentoDefault: ajustes.descuentoContado }
: {}),
})),
}),
},
]
}
it('sin reglas guardadas cotiza igual que siempre (respaldo en constantes)', async () => {
const { res, status } = resFake()
await crearCotizacion(reqFake(bodyBase), res)
expect(status).toHaveBeenCalledWith(201)
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
data: expect.objectContaining({ desarrollo: 'DIAGONAL', descuento: 0 }),
})
})
it('usa el descuento configurado por el admin', async () => {
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue(
reglasGuardadas('DIAGONAL', { descuentoContado: 0.1 }),
)
const { res, status } = resFake()
await crearCotizacion(reqFake({ ...bodyBase, tipoPropuesta: 'CONTADO' }), res)
expect(status).toHaveBeenCalledWith(201)
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
data: expect.objectContaining({ descuento: 0.1, precioFinal: 900_000 }),
})
})
it('propuesta desactivada → 409 con el mensaje del servidor y sin persistir', async () => {
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue(
reglasGuardadas('DIAGONAL', { propuestaInactiva: 'CREDITO_BANCARIO' }),
)
const { res } = resFake()
await expect(crearCotizacion(reqFake(bodyBase), res)).rejects.toMatchObject({
status: 409,
message: expect.stringContaining('La propuesta BANCARIO no está disponible'),
})
expect(prismaMock.cotizacion.create).not.toHaveBeenCalled()
// Las otras propuestas siguen cotizándose
const { res: res2, status } = resFake()
await crearCotizacion(reqFake({ ...bodyBase, tipoPropuesta: 'CONTADO' }), res2)
expect(status).toHaveBeenCalledWith(201)
})
it('desarrollo desactivado (activoEnCotizador: false) → 409 y sin persistir', async () => {
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue(
reglasGuardadas('DIAGONAL', { activoEnCotizador: false }),
)
const { res } = resFake()
await expect(crearCotizacion(reqFake(bodyBase), res)).rejects.toMatchObject({
status: 409,
message: expect.stringContaining('no está disponible en el cotizador'),
})
expect(prismaMock.cotizacion.create).not.toHaveBeenCalled()
})
it('JSON corrupto en la BD: cotiza con las constantes (un warning, sin fallar)', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue([
{ desarrolloId: 'DIAGONAL', parametrosJson: '{corrupto' },
])
const { res, status } = resFake()
await crearCotizacion(reqFake(bodyBase), res)
expect(status).toHaveBeenCalledWith(201)
expect(prismaMock.cotizacion.create).toHaveBeenCalledWith({
data: expect.objectContaining({ descuento: 0, precioLista: 1_000_000 }),
})
expect(warn).toHaveBeenCalledWith(expect.stringContaining('JSON inválido'))
warn.mockRestore()
})
})
describe('alertasInventarioEndpoint (POST /api/cotizaciones/alertas-inventario) — actividades locales', () => {
it('crea una ActividadLocal al vendedor de cada cotización reciente, con dedup', async () => {
prismaMock.cotizacion.findMany.mockResolvedValue([{ id: 'cot-9', vendedorId: 'usr-1' }])

View File

@@ -1,7 +1,13 @@
import type { Request, Response } from 'express'
import { z } from 'zod'
import { getUnidadLocal } from '../services/unidades-local.service.js'
import { calcularPropuesta, obtenerPrecioLista } from '../services/cotizacion.service.js'
import {
calcularPropuesta,
obtenerPrecioLista,
propuestasVigentes,
TIPO_PROPUESTA_API,
} from '../services/cotizacion.service.js'
import { getReglasDesarrollo } from '../services/reglas-desarrollo.service.js'
import {
procesarAlertasInventario,
type ResultadoAlertas,
@@ -76,6 +82,24 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
// el mismo inmueble ya apartado/vendido
validarUnidadDisponible(unidad)
// Reglas comerciales del desarrollo: las configuradas por el admin (BD) con
// respaldo en las constantes históricas. Un desarrollo o una propuesta
// desactivados se rechazan con el mensaje que muestra el cotizador.
const reglas = await getReglasDesarrollo(unidad.desarrollo)
if (reglas.activoEnCotizador === false) {
throw new AppError(
409,
`El desarrollo ${reglas.nombre ?? reglas.id} no está disponible en el cotizador. Elige una unidad de otro desarrollo.`,
)
}
const tipoInterno = TIPO_PROPUESTA_API[body.tipoPropuesta]
if (!propuestasVigentes(reglas).some((p) => p.tipo === tipoInterno)) {
throw new AppError(
409,
`La propuesta ${body.tipoPropuesta} no está disponible para el desarrollo ${reglas.id}. Elige otra propuesta.`,
)
}
const apartado = body.apartado ?? 20000 // APARTADO_DEFAULT
const propuesta = calcularPropuesta(
unidad,
@@ -85,6 +109,7 @@ export async function crearCotizacion(req: Request, res: Response): Promise<void
body.descuento,
body.enganchePct,
body.numMensualidades,
reglas,
)
const precioLista = obtenerPrecioLista(unidad, body.acabados)

View File

@@ -0,0 +1,524 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
// ---------- Prisma mockeado (el servicio corre de verdad) ----------
const prismaMock = vi.hoisted(() => ({
desarrollo: {
findUnique: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
modeloDesarrollo: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
},
reglaCotizadorDesarrollo: {
findUnique: vi.fn(),
create: vi.fn(),
upsert: vi.fn(),
count: vi.fn(),
},
unidad: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
},
cotizacion: { count: vi.fn(), findMany: vi.fn() },
leadCaptura: { count: vi.fn(), findMany: vi.fn() },
cliente: { count: vi.fn(), findMany: vi.fn() },
planPago: { count: vi.fn(), findMany: vi.fn() },
auditoriaLog: { create: vi.fn().mockResolvedValue({}) },
$transaction: vi.fn(async (ops: Promise<unknown>[]) => Promise.all(ops)),
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
import { requireAdminEstricto } from './config.controller.js'
import {
actualizarDesarrolloEndpoint,
agregarModeloEndpoint,
crearDesarrolloEndpoint,
crearUnidadesEndpoint,
eliminarDesarrolloEndpoint,
eliminarUnidadEndpoint,
guardarReglasEndpoint,
listarDesarrollosEndpoint,
listarDesarrollosPublicosEndpoint,
obtenerDesarrolloPublicoEndpoint,
} from './desarrollos.controller.js'
function reqFake(opts: { body?: unknown; params?: Record<string, string>; query?: Record<string, string>; token?: { sub: string; rol: string } }): Request {
return {
body: opts.body ?? {},
params: opts.params ?? {},
query: opts.query ?? {},
usuarioToken: opts.token,
} as unknown as Request
}
function resFake() {
const json = vi.fn()
const end = vi.fn()
const statusFn = vi.fn().mockReturnValue({ json, end })
return { res: { json, end, status: statusFn } as unknown as Response, status: statusFn, json, end }
}
const ADMIN = { sub: 'u-admin', rol: 'admin' }
function filaDesarrollo(over: Record<string, unknown> = {}) {
return {
id: 'd-1',
clave: 'SENDA',
nombre: 'Senda',
estado: 'ACTIVO',
tipo: 'VERTICAL',
constructora: null,
direccion: null,
zona: 'Zapopan',
descripcion: null,
amenidades: null,
fechaInicioObra: null,
fechaEntregaProgramada: null,
fechaEntregaReprogramada: null,
activoEnCotizador: true,
activoEnAlta: true,
prefijoUnidadPdf: 'CASA',
imagenUrl: null,
logoUrl: null,
createdAt: new Date('2026-09-21T00:00:00.000Z'),
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
modelos: [],
reglas: null,
...over,
}
}
function filaUnidad(over: Record<string, unknown> = {}) {
return {
id: 'u-1',
codigo: 'SENDA-1',
desarrollo: 'SENDA',
modelo: 'HIMALAYA',
manzana: '',
lote: '',
nivel: '',
supConstruccion: 120,
supExcedente: 0,
supEstacionamiento: 1,
precioLista: 3000000,
precioAcabados: 3200000,
status: 'DISPONIBLE',
etapa: '',
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
...over,
}
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.unidad.findMany.mockResolvedValue([])
prismaMock.unidad.count.mockResolvedValue(0)
prismaMock.cotizacion.count.mockResolvedValue(0)
prismaMock.leadCaptura.count.mockResolvedValue(0)
prismaMock.cliente.count.mockResolvedValue(0)
prismaMock.planPago.count.mockResolvedValue(0)
prismaMock.unidad.create.mockImplementation(async ({ data }: { data: Record<string, unknown> }) => ({
id: `id-${String(data['codigo'])}`,
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
...data,
}))
})
// ── Permisos ──────────────────────────────────────────────────────────
describe('requireAdminEstricto', () => {
it('deja pasar al admin', () => {
const next = vi.fn()
requireAdminEstricto(reqFake({ token: ADMIN }), {} as Response, next)
expect(next).toHaveBeenCalledOnce()
})
it('403 para manager y para vendedor', () => {
for (const rol of ['manager', 'vendedor', 'coordinador', 'corporativo']) {
expect(() => requireAdminEstricto(reqFake({ token: { sub: 'u-1', rol } }), {} as Response, vi.fn())).toThrow(
'Solo un administrador puede hacer esta operación',
)
}
})
it('403 sin token', () => {
expect(() => requireAdminEstricto(reqFake({}), {} as Response, vi.fn())).toThrow()
})
it('los endpoints de escritura quedan detrás del guard (403 con rol manager)', async () => {
// El guard se monta en las rutas; aquí se comprueba que corta antes del servicio
const req = reqFake({ token: { sub: 'u-1', rol: 'manager' }, body: { clave: 'NUEVO', nombre: 'Nuevo' } })
expect(() => requireAdminEstricto(req, {} as Response, vi.fn())).toThrow()
expect(prismaMock.desarrollo.create).not.toHaveBeenCalled()
})
})
// ── Fichas ────────────────────────────────────────────────────────────
describe('POST /api/desarrollos', () => {
it('201 con la ficha cuando la clave es nueva', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValueOnce(null).mockResolvedValueOnce(filaDesarrollo())
prismaMock.desarrollo.create.mockResolvedValue({ id: 'd-1' })
const { res, status, json } = resFake()
await crearDesarrolloEndpoint(reqFake({ body: { clave: 'SENDA', nombre: 'Senda' }, token: ADMIN }), res)
expect(status).toHaveBeenCalledWith(201)
expect(json).toHaveBeenCalledWith(expect.objectContaining({ clave: 'SENDA' }))
expect(prismaMock.auditoriaLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ accion: 'CREAR_DESARROLLO', entidad: 'desarrollo', usuarioId: 'u-admin' }),
})
})
it('409 con clave duplicada y sin crear', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-existente' })
const { res } = resFake()
await expect(crearDesarrolloEndpoint(reqFake({ body: { clave: 'SENDA', nombre: 'Senda' }, token: ADMIN }), res)).rejects.toMatchObject({
status: 409,
})
expect(prismaMock.desarrollo.create).not.toHaveBeenCalled()
})
it('400 si la clave no es mayúsculas sin espacios', async () => {
const { res } = resFake()
await expect(
crearDesarrolloEndpoint(reqFake({ body: { clave: 'senda nueva', nombre: 'Senda' }, token: ADMIN }), res),
).rejects.toThrow()
})
})
describe('GET /api/desarrollos y detalle', () => {
it('devuelve el contrato del listado', async () => {
prismaMock.desarrollo.findMany.mockResolvedValue([
filaDesarrollo({
modelos: [{ id: 'm-1', desarrolloId: 'd-1', nombre: 'HIMALAYA', recamaras: 3, banos: 2.5, estacionamientos: 2, supConstruccion: 120, activo: true }],
reglas: { desarrolloId: 'd-1', parametrosJson: '{}', updatedAt: new Date(), updatedPorId: null },
}),
])
prismaMock.unidad.findMany.mockResolvedValue([
{ desarrollo: 'SENDA', status: 'DISPONIBLE' },
{ desarrollo: 'SENDA', status: 'APARTADO' },
])
const { res, json } = resFake()
await listarDesarrollosEndpoint(reqFake({ token: ADMIN }), res)
expect(json).toHaveBeenCalledWith({
desarrollos: [
expect.objectContaining({
clave: 'SENDA',
unidadesTotales: 2,
unidadesDisponibles: 1,
tieneReglas: true,
modelos: [
{ id: 'm-1', nombre: 'HIMALAYA', recamaras: 3, banos: 2.5, estacionamientos: 2, supConstruccion: 120, activo: true },
],
}),
],
})
})
})
describe('PATCH /api/desarrollos/:id', () => {
it('404 si no existe', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue(null)
await expect(actualizarDesarrolloEndpoint(reqFake({ params: { id: 'nope' }, body: { zona: 'X' }, token: ADMIN }), resFake().res)).rejects.toMatchObject({
status: 404,
})
})
it('actualiza solo los campos enviados', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValueOnce({ id: 'd-1', clave: 'SENDA' }).mockResolvedValueOnce(filaDesarrollo({ zona: 'Tlajomulco' }))
prismaMock.desarrollo.update.mockResolvedValue(filaDesarrollo())
const { res, json } = resFake()
await actualizarDesarrolloEndpoint(reqFake({ params: { id: 'd-1' }, body: { zona: 'Tlajomulco' }, token: ADMIN }), res)
expect(prismaMock.desarrollo.update).toHaveBeenCalledWith({ where: { id: 'd-1' }, data: { zona: 'Tlajomulco' } })
expect(json).toHaveBeenCalledWith(expect.objectContaining({ zona: 'Tlajomulco' }))
})
})
describe('DELETE /api/desarrollos/:id', () => {
it('409 con inventario y operación; 204 sin dependencias', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.unidad.findMany.mockResolvedValue([{ codigo: 'SENDA-1' }, { codigo: 'SENDA-2' }])
prismaMock.cotizacion.count.mockResolvedValue(3)
await expect(eliminarDesarrolloEndpoint(reqFake({ params: { id: 'd-1' }, token: ADMIN }), resFake().res)).rejects.toMatchObject({
status: 409,
message: 'El desarrollo tiene 2 unidades y 3 registros de operación; desactívalo en vez de borrarlo',
})
expect(prismaMock.desarrollo.delete).not.toHaveBeenCalled()
})
it('204 y auditoría cuando el desarrollo está limpio', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
const { res, status } = resFake()
await eliminarDesarrolloEndpoint(reqFake({ params: { id: 'd-1' }, token: ADMIN }), res)
expect(status).toHaveBeenCalledWith(204)
expect(prismaMock.desarrollo.delete).toHaveBeenCalledWith({ where: { id: 'd-1' } })
expect(prismaMock.auditoriaLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ accion: 'ELIMINAR_DESARROLLO', entidadId: 'd-1' }),
})
})
})
// ── Modelos y reglas ──────────────────────────────────────────────────
describe('POST /api/desarrollos/:id/modelos', () => {
it('201 con el modelo creado', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1' })
prismaMock.modeloDesarrollo.findUnique.mockResolvedValue(null)
prismaMock.modeloDesarrollo.create.mockResolvedValue({ id: 'm-1', desarrolloId: 'd-1', nombre: 'PANORAMA' })
const { res, status } = resFake()
await agregarModeloEndpoint(reqFake({ params: { id: 'd-1' }, body: { nombre: 'PANORAMA', recamaras: 3 }, token: ADMIN }), res)
expect(status).toHaveBeenCalledWith(201)
})
})
describe('PUT /api/desarrollos/:id/reglas', () => {
const reglas = {
precioUnico: false,
fuenteDeN: 'fechaEntrega',
nDefault: 13,
cuotaMantenimiento: 1500,
prefijoUnidadPdf: 'CASA',
propuestas: [
{
tipo: 'CREDITO_BANCARIO',
claveDescuento: 'creditoBancario',
titulo: 'Crédito Bancario',
descuentoDefault: 0,
pctEnganche: 0.025,
engancheBase: 'lista',
pctPagoFinal: 0.9,
pagoFinalBase: 'final',
modoMensualidades: 'remanente',
},
],
}
it('guarda y devuelve las reglas parseadas', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1' })
prismaMock.reglaCotizadorDesarrollo.upsert.mockResolvedValue({
desarrolloId: 'd-1',
parametrosJson: JSON.stringify(reglas),
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
updatedPorId: 'u-admin',
})
const { res, json } = resFake()
await guardarReglasEndpoint(reqFake({ params: { id: 'd-1' }, body: reglas, token: ADMIN }), res)
expect(json).toHaveBeenCalledWith(expect.objectContaining({ desarrolloId: 'd-1', parametros: expect.objectContaining({ nDefault: 13 }) }))
})
it('400 si un porcentaje se sale de 0..1', async () => {
const malas = { ...reglas, propuestas: [{ ...reglas.propuestas[0], pctPagoFinal: 1.2 }] }
await expect(guardarReglasEndpoint(reqFake({ params: { id: 'd-1' }, body: malas, token: ADMIN }), resFake().res)).rejects.toThrow()
expect(prismaMock.reglaCotizadorDesarrollo.upsert).not.toHaveBeenCalled()
})
})
// ── Inventario ────────────────────────────────────────────────────────
describe('POST /api/desarrollos/:id/unidades', () => {
it('genera el rango con {clave} y {n}: 201 con las creadas', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
const { res, status, json } = resFake()
await crearUnidadesEndpoint(
reqFake({
params: { id: 'd-1' },
body: { generar: { patron: 'SENDA-401-{n}', desde: 1, hasta: 3, precioLista: 2000000 } },
token: ADMIN,
}),
res,
)
expect(status).toHaveBeenCalledWith(201)
expect(json).toHaveBeenCalledWith({
creadas: 3,
unidades: expect.arrayContaining([expect.objectContaining({ codigo: 'SENDA-401-1', status: 'DISPONIBLE' })]),
})
expect(prismaMock.auditoriaLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({ accion: 'CREAR_UNIDADES', entidad: 'unidad' }),
})
})
it('{clave} se sustituye por la clave del desarrollo', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
const { json } = resFake()
await crearUnidadesEndpoint(
reqFake({ params: { id: 'd-1' }, body: { generar: { patron: '{clave}-{n}', desde: 7, hasta: 8, precioLista: 1 } }, token: ADMIN }),
resFake().res,
)
void json
const creadas = prismaMock.unidad.create.mock.calls.map((c) => (c[0] as { data: { codigo: string } }).data.codigo)
expect(creadas).toEqual(['SENDA-7', 'SENDA-8'])
})
it('409 sin crear nada si algún código ya existe', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.unidad.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([{ codigo: 'SENDA-5' }])
const { res } = resFake()
await expect(
crearUnidadesEndpoint(
reqFake({
params: { id: 'd-1' },
body: {
unidades: [
{ codigo: 'SENDA-5', precioLista: 1 },
{ codigo: 'SENDA-6', precioLista: 1 },
],
},
token: ADMIN,
}),
res,
),
).rejects.toMatchObject({ status: 409 })
expect(prismaMock.unidad.create).not.toHaveBeenCalled()
})
it('409 si el mismo código se repite en la alta', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.unidad.findMany.mockResolvedValue([])
await expect(
crearUnidadesEndpoint(
reqFake({
params: { id: 'd-1' },
body: { unidades: [{ codigo: 'SENDA-1', precioLista: 1 }, { codigo: 'SENDA-1', precioLista: 2 }] },
token: ADMIN,
}),
resFake().res,
),
).rejects.toMatchObject({ status: 409 })
})
it('404 si el desarrollo no existe', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue(null)
await expect(
crearUnidadesEndpoint(reqFake({ params: { id: 'nope' }, body: { generar: { patron: '{n}', desde: 1, hasta: 1, precioLista: 1 } }, token: ADMIN }), resFake().res),
).rejects.toMatchObject({ status: 404 })
})
})
describe('DELETE /api/desarrollos/:id/unidades/:codigo', () => {
it('409 si la unidad tiene cotizaciones o ventas', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad())
prismaMock.planPago.count.mockResolvedValue(1)
await expect(
eliminarUnidadEndpoint(reqFake({ params: { id: 'd-1', codigo: 'SENDA-1' }, token: ADMIN }), resFake().res),
).rejects.toMatchObject({ status: 409 })
expect(prismaMock.unidad.delete).not.toHaveBeenCalled()
})
it('204 si la unidad está libre', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad())
const { res, status } = resFake()
await eliminarUnidadEndpoint(reqFake({ params: { id: 'd-1', codigo: 'SENDA-1' }, token: ADMIN }), res)
expect(status).toHaveBeenCalledWith(204)
})
})
// ── Público (sin auth) ────────────────────────────────────────────────
describe('GET /api/publico/desarrollos', () => {
it('publica solo el desarrollo activo con inventario disponible y sin datos personales', async () => {
prismaMock.desarrollo.findMany.mockResolvedValue([
filaDesarrollo({
modelos: [{ id: 'm-1', desarrolloId: 'd-1', nombre: 'HIMALAYA', recamaras: 3, banos: 2.5, estacionamientos: 2, supConstruccion: 120, activo: true }],
}),
])
prismaMock.unidad.findMany.mockResolvedValue([
filaUnidad({ codigo: 'SENDA-1', nivel: '3' }),
filaUnidad({ codigo: 'SENDA-2', status: 'VENDIDO', precioLista: 5000000 }),
])
const { res, json } = resFake()
await listarDesarrollosPublicosEndpoint(reqFake({}), res)
const payload = (json.mock.calls[0] as [ { desarrollos: Record<string, unknown>[] } ])[0]
const desarrollo = payload.desarrollos[0] as Record<string, unknown>
expect(prismaMock.desarrollo.findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { activoEnCotizador: true, estado: { not: 'CERRADO' } } }),
)
expect(Object.keys(desarrollo).sort()).toEqual(
[
'amenidades',
'clave',
'descripcion',
'direccion',
'estado',
'fechaEntregaProgramada',
'imagenUrl',
'logoUrl',
'modelos',
'nombre',
'tipo',
'unidadesDisponibles',
'zona',
].sort(),
)
expect(desarrollo['unidadesDisponibles']).toEqual([
{ codigo: 'SENDA-1', modelo: 'HIMALAYA', nivel: '3', precioLista: 3000000, precioAcabados: 3200000, supConstruccion: 120 },
])
// El camino público nunca consulta clientes, cotizaciones ni planes
expect(prismaMock.cliente.count).not.toHaveBeenCalled()
expect(prismaMock.cliente.findMany).not.toHaveBeenCalled()
expect(prismaMock.cotizacion.count).not.toHaveBeenCalled()
expect(prismaMock.planPago.count).not.toHaveBeenCalled()
expect(prismaMock.leadCaptura.count).not.toHaveBeenCalled()
for (const prohibido of ['cliente', 'email', 'telefono', 'curp', 'vendedor', 'apartado', 'rfc']) {
expect(JSON.stringify(payload).toLowerCase()).not.toContain(prohibido)
}
})
})
describe('GET /api/publico/desarrollos/:clave', () => {
it('404 si no existe o no es público', async () => {
prismaMock.desarrollo.findFirst.mockResolvedValue(null)
await expect(obtenerDesarrolloPublicoEndpoint(reqFake({ params: { clave: 'NOEXISTE' } }), resFake().res)).rejects.toMatchObject({
status: 404,
})
})
it('resuelve la clave sin importar mayúsculas', async () => {
prismaMock.desarrollo.findFirst.mockResolvedValue(filaDesarrollo())
const { res, json } = resFake()
await obtenerDesarrolloPublicoEndpoint(reqFake({ params: { clave: 'senda' } }), res)
expect(prismaMock.desarrollo.findFirst).toHaveBeenCalledWith(
expect.objectContaining({ where: { clave: 'SENDA', activoEnCotizador: true, estado: { not: 'CERRADO' } } }),
)
expect(json).toHaveBeenCalledWith(expect.objectContaining({ clave: 'SENDA' }))
})
})

View File

@@ -0,0 +1,141 @@
/**
* Endpoints del módulo Desarrollos.
*
* Todo el CRUD y el inventario son solo admin (`requireAdminEstricto`, montado
* en las rutas); el micrositio público lee /api/publico/desarrollos sin auth,
* con información exclusivamente comercial (nunca clientes ni cotizaciones).
*/
import type { Request, Response } from 'express'
import * as service from '../services/desarrollos.service.js'
import * as auditoria from '../services/auditoria.service.js'
import { AppError } from '../utils/errors.js'
/** Usuario del token para la bitácora (modo local sin auth: 'desconocido') */
function usuarioDe(req: Request): string {
return req.usuarioToken?.sub ?? 'desconocido'
}
function idDe(req: Request): string {
const id = String(req.params['id'] ?? '')
if (id === '') throw new AppError(400, 'Falta el parámetro :id')
return id
}
// ── Fichas ────────────────────────────────────────────────────────────
/** GET /api/desarrollos */
export async function listarDesarrollosEndpoint(_req: Request, res: Response): Promise<void> {
res.json(await service.listarDesarrollos())
}
/** GET /api/desarrollos/:id */
export async function obtenerDesarrolloEndpoint(req: Request, res: Response): Promise<void> {
res.json(await service.obtenerDesarrollo(idDe(req)))
}
/** POST /api/desarrollos */
export async function crearDesarrolloEndpoint(req: Request, res: Response): Promise<void> {
const body = service.crearDesarrolloSchema.parse(req.body)
const ficha = await service.crearDesarrollo(body)
res.status(201).json(ficha)
auditoria.registrar(usuarioDe(req), 'CREAR_DESARROLLO', 'desarrollo', ficha.id, ficha.clave)
}
/** PATCH /api/desarrollos/:id */
export async function actualizarDesarrolloEndpoint(req: Request, res: Response): Promise<void> {
const body = service.actualizarDesarrolloSchema.parse(req.body)
const ficha = await service.actualizarDesarrollo(idDe(req), body)
res.json(ficha)
auditoria.registrar(
usuarioDe(req),
'ACTUALIZAR_DESARROLLO',
'desarrollo',
ficha.id,
Object.keys(req.body as Record<string, unknown>).join(', '),
)
}
/** DELETE /api/desarrollos/:id — 409 si tiene inventario u operación */
export async function eliminarDesarrolloEndpoint(req: Request, res: Response): Promise<void> {
const id = idDe(req)
await service.eliminarDesarrollo(id)
res.status(204).end()
auditoria.registrar(usuarioDe(req), 'ELIMINAR_DESARROLLO', 'desarrollo', id)
}
// ── Modelos ───────────────────────────────────────────────────────────
/** POST /api/desarrollos/:id/modelos */
export async function agregarModeloEndpoint(req: Request, res: Response): Promise<void> {
const desarrolloId = idDe(req)
const body = service.crearModeloSchema.parse(req.body)
const modelo = await service.agregarModelo(desarrolloId, body)
res.status(201).json(modelo)
auditoria.registrar(usuarioDe(req), 'AGREGAR_MODELO', 'desarrollo', desarrolloId, modelo.nombre)
}
/** PATCH /api/desarrollos/modelos/:modeloId */
export async function actualizarModeloEndpoint(req: Request, res: Response): Promise<void> {
const modeloId = String(req.params['modeloId'])
const body = service.actualizarModeloSchema.parse(req.body)
const modelo = await service.actualizarModelo(modeloId, body)
res.json(modelo)
auditoria.registrar(usuarioDe(req), 'ACTUALIZAR_MODELO', 'desarrollo', modelo.desarrolloId, modelo.nombre)
}
/** DELETE /api/desarrollos/modelos/:modeloId — 409 si el modelo tiene unidades */
export async function eliminarModeloEndpoint(req: Request, res: Response): Promise<void> {
const modeloId = String(req.params['modeloId'])
await service.eliminarModelo(modeloId)
res.status(204).end()
auditoria.registrar(usuarioDe(req), 'ELIMINAR_MODELO', 'desarrollo', modeloId)
}
// ── Reglas del cotizador ──────────────────────────────────────────────
/** PUT /api/desarrollos/:id/reglas */
export async function guardarReglasEndpoint(req: Request, res: Response): Promise<void> {
const desarrolloId = idDe(req)
const parametros = service.parametrosDesarrolloSchema.parse(req.body)
const reglas = await service.guardarReglas(desarrolloId, parametros, req.usuarioToken?.sub ?? null)
res.json(reglas)
auditoria.registrar(usuarioDe(req), 'GUARDAR_REGLAS_COTIZADOR', 'desarrollo', desarrolloId)
}
// ── Inventario ────────────────────────────────────────────────────────
/** POST /api/desarrollos/:id/unidades — alta manual o por generador */
export async function crearUnidadesEndpoint(req: Request, res: Response): Promise<void> {
const desarrolloId = idDe(req)
const body = service.crearUnidadesSchema.parse(req.body)
const resultado = await service.crearUnidades(desarrolloId, body)
res.status(201).json(resultado)
auditoria.registrar(
usuarioDe(req),
'CREAR_UNIDADES',
'unidad',
desarrolloId,
`${resultado.creadas} unidad(es): ${resultado.unidades.map((u) => u.codigo).join(', ')}`,
)
}
/** DELETE /api/desarrollos/:id/unidades/:codigo — 409 si la unidad tiene operación */
export async function eliminarUnidadEndpoint(req: Request, res: Response): Promise<void> {
const desarrolloId = idDe(req)
const codigo = String(req.params['codigo'])
await service.eliminarUnidad(desarrolloId, codigo)
res.status(204).end()
auditoria.registrar(usuarioDe(req), 'ELIMINAR_UNIDAD', 'unidad', codigo)
}
// ── Público (micrositio) ──────────────────────────────────────────────
/** GET /api/publico/desarrollos */
export async function listarDesarrollosPublicosEndpoint(_req: Request, res: Response): Promise<void> {
res.json(await service.listarDesarrollosPublicos())
}
/** GET /api/publico/desarrollos/:clave */
export async function obtenerDesarrolloPublicoEndpoint(req: Request, res: Response): Promise<void> {
res.json(await service.obtenerDesarrolloPublico(String(req.params['clave'])))
}

View File

@@ -0,0 +1,45 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { Request, Response } from 'express'
const prismaMock = vi.hoisted(() => ({
reglaCotizadorDesarrollo: { findMany: vi.fn() },
}))
vi.mock('../services/prisma.js', () => ({ prisma: prismaMock }))
import { reglasCotizadorEndpoint } from './reglas-cotizador.controller.js'
import { DESARROLLOS } from '../services/cotizacion.service.js'
function resFake() {
const json = vi.fn()
return { res: { json } as unknown as Response, json }
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue([])
})
describe('GET /api/desarrollos/reglas-cotizador', () => {
it('devuelve el mapa reglasPorDesarrollo con las reglas efectivas', async () => {
prismaMock.reglaCotizadorDesarrollo.findMany.mockResolvedValue([
{
desarrolloId: 'SENDA',
parametrosJson: JSON.stringify(DESARROLLOS['SENDA']),
activoEnCotizador: false,
},
])
const { res, json } = resFake()
await reglasCotizadorEndpoint({} as Request, res)
const cuerpo = json.mock.calls[0]?.[0] as {
reglasPorDesarrollo: Record<string, { activoEnCotizador?: boolean; propuestas: unknown[] }>
}
expect(Object.keys(cuerpo.reglasPorDesarrollo).sort()).toEqual(Object.keys(DESARROLLOS).sort())
expect(cuerpo.reglasPorDesarrollo['SENDA']?.activoEnCotizador).toBe(false)
expect(cuerpo.reglasPorDesarrollo['SENDA']?.propuestas).toHaveLength(4)
// Los desarrollos sin reglas guardadas salen con sus constantes
expect(cuerpo.reglasPorDesarrollo['DIAGONAL']).toEqual(DESARROLLOS['DIAGONAL'])
})
})

View File

@@ -0,0 +1,14 @@
import type { Request, Response } from 'express'
import { getReglasEfectivas } from '../services/reglas-desarrollo.service.js'
/**
* GET /api/desarrollos/reglas-cotizador — reglas comerciales EFECTIVAS por
* desarrollo (las configuradas por el admin en la BD, con respaldo en las
* constantes históricas para los desarrollos sin reglas guardadas).
*
* Autenticado y disponible para cualquier rol que cotice (el middleware de auth
* cubre todo /api salvo las rutas públicas). Devuelve el mapa `reglasPorDesarrollo`.
*/
export async function reglasCotizadorEndpoint(_req: Request, res: Response): Promise<void> {
res.json({ reglasPorDesarrollo: await getReglasEfectivas() })
}

View File

@@ -124,6 +124,34 @@ describe('authMiddleware (AUTH_ENABLED=true)', () => {
}
})
it('deja pasar los endpoints públicos de desarrollos (micrositio sin sesión)', () => {
const casos: Partial<Request>[] = [
{ method: 'GET', path: '/publico/desarrollos' },
{ method: 'GET', path: '/publico/desarrollos/SENDA' },
]
for (const c of casos) {
vi.clearAllMocks()
authMiddleware(reqFake(c), resFake().res, next)
expect(next).toHaveBeenCalledOnce()
}
})
it('el patrón público de desarrollos no abre el CRUD de admin', () => {
const casos: Partial<Request>[] = [
{ method: 'POST', path: '/publico/desarrollos' },
{ method: 'DELETE', path: '/publico/desarrollos/SENDA' },
{ method: 'GET', path: '/publico/desarrollos/SENDA/unidades' },
{ method: 'GET', path: '/desarrollos' },
{ method: 'GET', path: '/desarrollos/d-1' },
{ method: 'POST', path: '/desarrollos/d-1/unidades' },
]
for (const c of casos) {
const { res, status } = resFake()
authMiddleware(reqFake(c), res, next)
expect(status).toHaveBeenCalledWith(401)
}
})
it('el token interno abre solo POST /cotizaciones/alertas-inventario', () => {
authMiddleware(
reqFake({

View File

@@ -20,6 +20,10 @@ const PATRONES_PUBLICOS: ReadonlyArray<{ metodo: string; patron: RegExp }> = [
{ metodo: 'GET', patron: /^\/clientes\/publico\/[^/]+$/ },
{ metodo: 'POST', patron: /^\/clientes\/publico\/[^/]+\/firma$/ },
{ metodo: 'POST', patron: /^\/clientes\/publico\/[^/]+\/documentos$/ },
// Micrositio: fichas públicas de desarrollos y su inventario disponible
// (solo información comercial, jamás datos de clientes ni cotizaciones).
{ metodo: 'GET', patron: /^\/publico\/desarrollos$/ },
{ metodo: 'GET', patron: /^\/publico\/desarrollos\/[^/]+$/ },
]
declare module 'express-serve-static-core' {

View File

@@ -2,6 +2,7 @@ import { AppError } from '../utils/errors.js'
import { Router } from 'express'
import { health } from '../controllers/health.controller.js'
import { listarUnidades, obtenerUnidad } from '../controllers/unidades.controller.js'
import { reglasCotizadorEndpoint } from '../controllers/reglas-cotizador.controller.js'
import {
alertasInventarioEndpoint,
crearCotizacion,
@@ -163,6 +164,21 @@ import {
requireAdminOManager,
uploadArchivosSoporte,
} from '../controllers/soporte.controller.js'
import {
actualizarDesarrolloEndpoint,
actualizarModeloEndpoint,
agregarModeloEndpoint,
crearDesarrolloEndpoint,
crearUnidadesEndpoint,
eliminarDesarrolloEndpoint,
eliminarModeloEndpoint,
eliminarUnidadEndpoint,
guardarReglasEndpoint,
listarDesarrollosEndpoint,
listarDesarrollosPublicosEndpoint,
obtenerDesarrolloEndpoint,
obtenerDesarrolloPublicoEndpoint,
} from '../controllers/desarrollos.controller.js'
export const apiRouter = Router()
@@ -176,6 +192,11 @@ apiRouter.post('/auth/cambiar-password', cambiarPasswordEndpoint)
apiRouter.get('/unidades', listarUnidades)
apiRouter.get('/unidades/:codigo', obtenerUnidad)
// Reglas comerciales del cotizador (BD del admin con respaldo en constantes).
// Ruta fija: debe ir ANTES de cualquier '/desarrollos/:id' que agregue el
// módulo de administración para no quedar tapada por el parámetro.
apiRouter.get('/desarrollos/reglas-cotizador', reglasCotizadorEndpoint)
apiRouter.post('/cotizaciones', crearCotizacion)
apiRouter.get('/cotizaciones', listarCotizaciones)
apiRouter.get('/cotizaciones/mias', listarMisCotizaciones)
@@ -365,3 +386,22 @@ apiRouter.post('/soporte', uploadArchivosSoporte, crearSolicitudSoporteEndpoint)
apiRouter.get('/soporte', listarSolicitudesSoporteEndpoint)
apiRouter.get('/soporte/:id/archivos/:indice', descargarArchivoSoporteEndpoint)
apiRouter.patch('/soporte/:id', requireAdminOManager, cambiarEstadoSolicitudEndpoint)
// ── Módulo Desarrollos: catálogo en BD (solo admin) ─────────────────
// Público primero (sin auth, lo consume el micrositio): ver RUTAS_PUBLICAS/
// PATRONES_PUBLICOS en middleware/auth.middleware.ts
apiRouter.get('/publico/desarrollos', listarDesarrollosPublicosEndpoint)
apiRouter.get('/publico/desarrollos/:clave', obtenerDesarrolloPublicoEndpoint)
apiRouter.get('/desarrollos', requireAdminEstricto, listarDesarrollosEndpoint)
apiRouter.post('/desarrollos', requireAdminEstricto, crearDesarrolloEndpoint)
// Rutas fijas de modelos ANTES de /desarrollos/:id para no colisionar con el parámetro
apiRouter.patch('/desarrollos/modelos/:modeloId', requireAdminEstricto, actualizarModeloEndpoint)
apiRouter.delete('/desarrollos/modelos/:modeloId', requireAdminEstricto, eliminarModeloEndpoint)
apiRouter.get('/desarrollos/:id', requireAdminEstricto, obtenerDesarrolloEndpoint)
apiRouter.patch('/desarrollos/:id', requireAdminEstricto, actualizarDesarrolloEndpoint)
apiRouter.delete('/desarrollos/:id', requireAdminEstricto, eliminarDesarrolloEndpoint)
apiRouter.post('/desarrollos/:id/modelos', requireAdminEstricto, agregarModeloEndpoint)
apiRouter.put('/desarrollos/:id/reglas', requireAdminEstricto, guardarReglasEndpoint)
apiRouter.post('/desarrollos/:id/unidades', requireAdminEstricto, crearUnidadesEndpoint)
apiRouter.delete('/desarrollos/:id/unidades/:codigo', requireAdminEstricto, eliminarUnidadEndpoint)

View File

@@ -0,0 +1,201 @@
import { describe, expect, it } from 'vitest'
import {
APARTADO_DEFAULT,
DESARROLLOS as DESARROLLOS_LOCALES,
calcularPropuesta,
calcularPropuestas,
totalPagado,
type DescuentosPropuestas,
type ParametrosDesarrollo,
type TipoPropuestaApi,
type UnidadCotizable,
} from './cotizacion.service.js'
import { normalizarReglas } from './reglas-desarrollo.service.js'
/**
* Compatibilidad de las reglas configurables: cotizar con las reglas que llegan
* de la BD (mismo JSON que guarda `ReglaCotizadorDesarrollo`, validado por
* `normalizarReglas`) debe dar EXACTAMENTE los mismos números que con las
* constantes históricas. Unidades: 3 reales por desarrollo del inventario.
*/
function unidad(parcial: Partial<UnidadCotizable> & Pick<UnidadCotizable, 'codigo' | 'desarrollo'>): UnidadCotizable {
return {
modelo: 'X',
fechaEntrega: null,
supEstacionamiento: 0,
supConstruccion: 0,
supExcedente: 0,
precioSinAcabados: 0,
precioConAcabados: 0,
status: 'DISPONIBLE',
...parcial,
}
}
const UNIDADES: UnidadCotizable[] = [
// DIAGONAL (precio varía con acabados)
unidad({ codigo: 'U-101', desarrollo: 'DIAGONAL', fechaEntrega: '2027-10-08', precioSinAcabados: 4415000, precioConAcabados: 4579000 }),
unidad({ codigo: 'U-103', desarrollo: 'DIAGONAL', fechaEntrega: '2027-10-08', precioSinAcabados: 3107108.39, precioConAcabados: 3269000 }),
unidad({ codigo: 'U-201', desarrollo: 'DIAGONAL', fechaEntrega: null, precioSinAcabados: 3990000, precioConAcabados: 4150000 }),
// ATELIER (precio único, N fijo = 1)
unidad({ codigo: 'ATELIER-10', desarrollo: 'ATELIER', precioSinAcabados: 3997500, precioConAcabados: 3997500 }),
unidad({ codigo: 'ATELIER-17', desarrollo: 'ATELIER', precioSinAcabados: 3554291, precioConAcabados: 3554291 }),
unidad({ codigo: 'ATELIER-27', desarrollo: 'ATELIER', precioSinAcabados: 2304339, precioConAcabados: 2304339 }),
// ESTANZA (pago final sobre precio de LISTA: el "quirk" del Excel)
unidad({ codigo: 'ESTANZA-17', desarrollo: 'ESTANZA', precioSinAcabados: 2617632, precioConAcabados: 2617632 }),
unidad({ codigo: 'ESTANZA-19', desarrollo: 'ESTANZA', precioSinAcabados: 2340562, precioConAcabados: 2340562 }),
unidad({ codigo: 'ESTANZA-25', desarrollo: 'ESTANZA', precioSinAcabados: 2501683, precioConAcabados: 2501683 }),
// SENDA (N por fecha de entrega; hay piezas sin fecha → nDefault)
unidad({ codigo: 'SENDA-409-1-2', desarrollo: 'SENDA', fechaEntrega: '2026-12-19', precioSinAcabados: 2700000, precioConAcabados: 2700000 }),
unidad({ codigo: 'SENDA-411-1-7', desarrollo: 'SENDA', fechaEntrega: null, precioSinAcabados: 2451750, precioConAcabados: 2451750 }),
unidad({ codigo: 'SENDA-409-1-4', desarrollo: 'SENDA', fechaEntrega: '2026-11-30', precioSinAcabados: 2451750, precioConAcabados: 2451750 }),
// SOLANA (N por unidad: meses para firma; en el espejo viene null → nDefault = 1)
unidad({ codigo: 'SOLANA-6', desarrollo: 'SOLANA', precioSinAcabados: 2560000, precioConAcabados: 2560000 }),
unidad({ codigo: 'SOLANA-7', desarrollo: 'SOLANA', precioSinAcabados: 2730000, precioConAcabados: 2730000 }),
unidad({ codigo: 'SOLANA-9', desarrollo: 'SOLANA', precioSinAcabados: 2990445, precioConAcabados: 2990445 }),
]
const TIPOS: TipoPropuestaApi[] = ['BANCARIO', 'INFONAVIT', 'CONTADO', 'SEMI_CONTADO']
const DESCUENTOS: DescuentosPropuestas = {
creditoBancario: 0.01,
creditoInfonavit: 0.005,
contado: 0.03,
semiContado: 0.015,
}
/**
* Reglas "de BD": el JSON serializado tal como lo guarda el admin, con los
* campos extra que agrega el módulo de administración (nombre, activo, flags).
* Pasa por `normalizarReglas`, el mismo camino que usa el API al leerlas.
*/
function reglasDeBd(desarrollo: string): ParametrosDesarrollo {
const constantes = DESARROLLOS_LOCALES[desarrollo]!
const json = JSON.parse(
JSON.stringify({
...constantes,
nombre: desarrollo,
activoEnCotizador: true,
propuestas: constantes.propuestas.map((p) => ({ ...p, activa: true })),
}),
) as unknown
const reglas = normalizarReglas(desarrollo, json)
if (!reglas) throw new Error(`Las reglas de BD de ${desarrollo} deberían ser válidas`)
return reglas
}
describe('reglas de BD vs constantes: mismos números', () => {
it('las 4 propuestas de cada unidad dan resultados idénticos', () => {
for (const u of UNIDADES) {
const reglas = reglasDeBd(u.desarrollo)
for (const acabados of [false, true]) {
const conConstantes = calcularPropuestas({
unidad: u,
acabados,
apartado: APARTADO_DEFAULT,
descuentos: DESCUENTOS,
})
const conBd = calcularPropuestas({
unidad: u,
acabados,
apartado: APARTADO_DEFAULT,
descuentos: DESCUENTOS,
reglas,
})
expect(conBd).toHaveLength(conConstantes.length)
expect(conBd).toEqual(conConstantes)
for (const p of conBd) {
expect(totalPagado(p, APARTADO_DEFAULT)).not.toBeNull()
}
}
}
})
it('la cotización de una propuesta (con descuento, enganche y N del vendedor) es idéntica', () => {
for (const u of UNIDADES) {
const reglas = reglasDeBd(u.desarrollo)
for (const tipo of TIPOS) {
for (const apartado of [0, APARTADO_DEFAULT, 150_000]) {
const conConstantes = calcularPropuesta(u, tipo, false, apartado, 0.01, 0.04, 12)
const conBd = calcularPropuesta(u, tipo, false, apartado, 0.01, 0.04, 12, reglas)
expect(conBd).toEqual(conConstantes)
}
}
}
})
it('sin reglas en el parámetro se comporta como hoy (respaldo en constantes)', () => {
const u = UNIDADES[0]!
expect(calcularPropuesta(u, 'BANCARIO', false, APARTADO_DEFAULT, undefined, undefined, undefined, null)).toEqual(
calcularPropuesta(u, 'BANCARIO', false, APARTADO_DEFAULT),
)
})
})
describe('reglas del admin que cambian el comportamiento', () => {
const u101 = UNIDADES[0]!
it('un descuento distinto en la BD se usa en el cálculo', () => {
const base = DESARROLLOS_LOCALES['DIAGONAL']!
const reglas = normalizarReglas('DIAGONAL', {
...base,
propuestas: base.propuestas.map((p) =>
p.tipo === 'CONTADO' ? { ...p, descuentoDefault: 0.05 } : p,
),
})!
const [bancario, , contado] = calcularPropuestas({
unidad: u101,
acabados: false,
apartado: APARTADO_DEFAULT,
descuentos: { creditoBancario: 0, creditoInfonavit: 0, contado: 0.05, semiContado: 0 },
reglas,
})
expect(bancario?.precioFinal).toBeCloseTo(4_415_000, 2)
expect(contado?.descuento).toBe(0.05)
expect(contado?.precioFinal).toBeCloseTo(4_415_000 * 0.95, 2)
})
it('una propuesta desactivada no se calcula ni se puede elegir', () => {
const base = DESARROLLOS_LOCALES['DIAGONAL']!
const reglas = normalizarReglas('DIAGONAL', {
...base,
propuestas: base.propuestas.map((p) => ({ ...p, activa: p.tipo !== 'CONTADO' })),
})!
const propuestas = calcularPropuestas({
unidad: u101,
acabados: false,
apartado: APARTADO_DEFAULT,
descuentos: DESCUENTOS,
reglas,
})
expect(propuestas.map((p) => p.tipo)).toEqual([
'CREDITO_BANCARIO',
'CREDITO_INFONAVIT_COFINAVIT',
'SEMI_CONTADO',
])
expect(() =>
calcularPropuesta(u101, 'CONTADO', false, APARTADO_DEFAULT, undefined, undefined, undefined, reglas),
).toThrow(/desactivada/)
})
it('un cambio de parámetros del Excel se refleja (pctEnganche / base del pago final)', () => {
const base = DESARROLLOS_LOCALES['ESTANZA']!
const reglas = normalizarReglas('ESTANZA', {
...base,
propuestas: base.propuestas.map((p) =>
p.tipo === 'CREDITO_BANCARIO' ? { ...p, pctEnganche: 0.1, pagoFinalBase: 'final' } : p,
),
})!
const casa = UNIDADES.find((u) => u.codigo === 'ESTANZA-17')!
const p = calcularPropuesta(casa, 'BANCARIO', false, APARTADO_DEFAULT, undefined, undefined, undefined, reglas)
// 10% de enganche sobre precio final, pago final 90% sobre final
expect(p.enganche).toBeCloseTo(0.1 * 2617632 - APARTADO_DEFAULT, 2)
expect(p.pagoFinal).toBeCloseTo(0.9 * 2617632, 2)
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(2617632, 2)
})
})

View File

@@ -48,10 +48,10 @@ export const DESCUENTOS_DEFAULT: DescuentosPropuestas = {
// ---------- Parametrización por desarrollo ----------
type BasePrecio = 'lista' | 'final'
type ModoMensualidades = 'ninguna' | 'remanente' | 'remanenteLista' | 'porcentaje'
export type BasePrecio = 'lista' | 'final'
export type ModoMensualidades = 'ninguna' | 'remanente' | 'remanenteLista' | 'porcentaje'
interface PropuestaConfig {
export interface PropuestaConfig {
tipo: TipoPropuesta
claveDescuento: keyof DescuentosPropuestas
titulo: string
@@ -62,10 +62,17 @@ interface PropuestaConfig {
pagoFinalBase: BasePrecio
modoMensualidades: ModoMensualidades
engancheCeroSinApartado?: boolean
/**
* Regla del admin: false => la propuesta no se ofrece ni se puede elegir en
* el cotizador. Ausente/true => vigente (comportamiento histórico).
*/
activa?: boolean
}
interface ParametrosDesarrollo {
export interface ParametrosDesarrollo {
id: string
/** Nombre para mostrar (solo lo traen las reglas guardadas por el admin) */
nombre?: string
precioUnico: boolean
fuenteDeN: 'fechaEntrega' | 'fijo' | 'porUnidad'
nFijo?: number
@@ -75,6 +82,15 @@ interface ParametrosDesarrollo {
* siempre se calculen (espejo de apps/cotizador/src/data/desarrollos.ts).
*/
nDefault?: number
/** Cuota mensual de mantenimiento (MXN); null => pendiente */
cuotaMantenimiento?: number | null
/** Prefijo de la unidad en el PDF ('CASA', 'DEPTO') */
prefijoUnidadPdf?: string
/**
* Regla del admin: false => el desarrollo no se ofrece en el cotizador.
* Ausente/true => disponible (comportamiento histórico).
*/
activoEnCotizador?: boolean
propuestas: PropuestaConfig[]
}
@@ -153,10 +169,23 @@ export const DESARROLLOS: Record<string, ParametrosDesarrollo> = {
const DESARROLLO_DEFAULT = 'DIAGONAL'
function obtenerDesarrollo(id?: string | null): ParametrosDesarrollo {
/**
* Reglas comerciales del desarrollo: las configuradas por el admin (BD) ganan
* cuando corresponden al id pedido; si no, se usan las constantes del módulo.
*/
function obtenerDesarrollo(
id?: string | null,
reglas?: ParametrosDesarrollo | null,
): ParametrosDesarrollo {
if (reglas && (id === undefined || id === null || id === '' || reglas.id === id)) return reglas
return (id && DESARROLLOS[id]) || DESARROLLOS[DESARROLLO_DEFAULT]!
}
/** Propuestas vigentes: el admin puede desactivar una propuesta por desarrollo */
export function propuestasVigentes(params: ParametrosDesarrollo): PropuestaConfig[] {
return params.propuestas.filter((p) => p.activa !== false)
}
export interface PropuestaResultado {
tipo: TipoPropuesta
titulo: string
@@ -177,6 +206,11 @@ export interface CotizacionInput {
apartado: number
descuentos: DescuentosPropuestas
desarrollo?: string
/**
* Reglas comerciales efectivas del desarrollo (las configuradas por el admin
* o su respaldo en constantes). Si no vienen, se usan las constantes.
*/
reglas?: ParametrosDesarrollo
/** Permite forzar N en tests; por defecto se resuelve según la fuenteDeN del desarrollo */
numMeses?: number
}
@@ -356,13 +390,16 @@ export function indiceMenorTotal(totales: number[]): number {
return indice
}
/** Calcula las propuestas comerciales del desarrollo de la unidad */
/** Calcula las propuestas comerciales vigentes del desarrollo de la unidad */
export function calcularPropuestas(input: CotizacionInput): PropuestaResultado[] {
const { unidad, acabados, apartado, descuentos } = input
const params = obtenerDesarrollo(input.desarrollo ?? unidad.desarrollo ?? DESARROLLO_DEFAULT)
const params = obtenerDesarrollo(
input.desarrollo ?? unidad.desarrollo ?? DESARROLLO_DEFAULT,
input.reglas ?? null,
)
const precioLista = obtenerPrecioLista(unidad, acabados)
const n = input.numMeses ?? resolverNumMensualidades(unidad, params)
return params.propuestas.map((cfg) =>
return propuestasVigentes(params).map((cfg) =>
calcularPropuestaConfig(cfg, precioLista, apartado, descuentos[cfg.claveDescuento], n),
)
}
@@ -386,6 +423,8 @@ export type TipoPropuestaApi = keyof typeof TIPO_PROPUESTA_API
* crédito (bancario/Infonavit); Contado y Semi Contado usan su % fijo.
* `numMeses` opcional: número de mensualidades elegido por el vendedor en el
* cotizador; si no viene se resuelve con la fuente del desarrollo.
* `reglas` opcional: reglas comerciales efectivas del desarrollo de la unidad
* (BD del admin con respaldo en constantes); sin ellas se usan las constantes.
*/
export function calcularPropuesta(
unidad: UnidadCotizable,
@@ -395,12 +434,18 @@ export function calcularPropuesta(
descuento?: number,
enganchePct?: number,
numMeses?: number,
reglas?: ParametrosDesarrollo | null,
): PropuestaResultado {
const params = obtenerDesarrollo(unidad.desarrollo)
const params = obtenerDesarrollo(unidad.desarrollo, reglas ?? null)
const tipoInterno = TIPO_PROPUESTA_API[tipo]
const cfg = params.propuestas.find((p) => p.tipo === tipoInterno)
const cfg = propuestasVigentes(params).find((p) => p.tipo === tipoInterno)
if (!cfg) {
throw new Error(`La propuesta ${tipo} no aplica para el desarrollo ${params.id}`)
const desactivada = params.propuestas.some((p) => p.tipo === tipoInterno)
throw new Error(
desactivada
? `La propuesta ${tipo} está desactivada para el desarrollo ${params.id}`
: `La propuesta ${tipo} no aplica para el desarrollo ${params.id}`,
)
}
const precioLista = obtenerPrecioLista(unidad, acabados)
const n = numMeses ?? resolverNumMensualidades(unidad, params)

View File

@@ -0,0 +1,496 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ---------- Prisma mockeado ----------
const prismaMock = vi.hoisted(() => ({
desarrollo: {
findUnique: vi.fn(),
findFirst: vi.fn(),
findMany: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
modeloDesarrollo: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
},
reglaCotizadorDesarrollo: {
findUnique: vi.fn(),
create: vi.fn(),
upsert: vi.fn(),
count: vi.fn(),
},
unidad: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
count: vi.fn(),
},
cotizacion: { count: vi.fn() },
leadCaptura: { count: vi.fn() },
cliente: { count: vi.fn() },
planPago: { count: vi.fn() },
$transaction: vi.fn(async (ops: Promise<unknown>[]) => Promise.all(ops)),
}))
vi.mock('./prisma.js', () => ({ prisma: prismaMock }))
import {
actualizarDesarrollo,
agregarModelo,
crearDesarrollo,
crearUnidades,
crearUnidadesSchema,
eliminarDesarrollo,
eliminarModelo,
eliminarUnidad,
guardarReglas,
listarDesarrollos,
listarDesarrollosPublicos,
obtenerDesarrolloPublico,
parametrosDesarrolloSchema,
} from './desarrollos.service.js'
/** Fila de Desarrollo tal como la devuelve Prisma (con relaciones) */
function filaDesarrollo(over: Record<string, unknown> = {}) {
return {
id: 'd-1',
clave: 'SENDA',
nombre: 'Senda',
estado: 'ACTIVO',
tipo: 'VERTICAL',
constructora: null,
direccion: null,
zona: 'Zapopan',
descripcion: null,
amenidades: 'alberca, gym',
fechaInicioObra: null,
fechaEntregaProgramada: new Date('2026-12-01T00:00:00.000Z'),
fechaEntregaReprogramada: null,
activoEnCotizador: true,
activoEnAlta: true,
prefijoUnidadPdf: 'CASA',
imagenUrl: null,
logoUrl: null,
createdAt: new Date('2026-09-21T00:00:00.000Z'),
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
modelos: [],
reglas: null,
...over,
}
}
function filaUnidad(over: Record<string, unknown> = {}) {
return {
id: 'u-1',
codigo: 'SENDA-1',
desarrollo: 'SENDA',
modelo: 'HIMALAYA',
manzana: '',
lote: '',
nivel: '',
supConstruccion: 120,
supExcedente: 0,
supEstacionamiento: 1,
precioLista: 3000000,
precioAcabados: 3200000,
status: 'DISPONIBLE',
etapa: '',
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
...over,
}
}
const REGLAS_VALIDAS = {
precioUnico: true,
fuenteDeN: 'fechaEntrega' as const,
nDefault: 6,
cuotaMantenimiento: null,
prefijoUnidadPdf: 'CASA',
propuestas: [
{
tipo: 'CONTADO' as const,
claveDescuento: 'contado' as const,
titulo: 'Contado',
descuentoDefault: 0.02,
pctEnganche: 0.9,
engancheBase: 'final' as const,
pctPagoFinal: 0.1,
pagoFinalBase: 'final' as const,
modoMensualidades: 'ninguna' as const,
},
],
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.unidad.findMany.mockResolvedValue([])
prismaMock.unidad.count.mockResolvedValue(0)
prismaMock.cotizacion.count.mockResolvedValue(0)
prismaMock.leadCaptura.count.mockResolvedValue(0)
prismaMock.cliente.count.mockResolvedValue(0)
prismaMock.planPago.count.mockResolvedValue(0)
// create devuelve lo que recibe (más id/updatedAt), como Prisma
prismaMock.unidad.create.mockImplementation(async ({ data }: { data: Record<string, unknown> }) => ({
id: `id-${String(data['codigo'])}`,
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
...data,
}))
})
// ── Fichas ────────────────────────────────────────────────────────────
describe('crearDesarrollo', () => {
it('rechaza una clave duplicada con 409', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-existente' })
await expect(crearDesarrollo({ clave: 'SENDA', nombre: 'Senda' })).rejects.toMatchObject({
status: 409,
message: 'Ya existe un desarrollo con la clave SENDA',
})
expect(prismaMock.desarrollo.create).not.toHaveBeenCalled()
})
it('normaliza la clave a mayúsculas y aplica los defaults', async () => {
prismaMock.desarrollo.findUnique
.mockResolvedValueOnce(null) // no existe la clave
.mockResolvedValueOnce(filaDesarrollo()) // lectura de la ficha creada
prismaMock.desarrollo.create.mockResolvedValue({ id: 'd-1' })
const ficha = await crearDesarrollo({ clave: ' senda ', nombre: 'Senda' })
expect(prismaMock.desarrollo.create).toHaveBeenCalledWith({
data: expect.objectContaining({
clave: 'SENDA',
nombre: 'Senda',
estado: 'ACTIVO',
tipo: 'VERTICAL',
activoEnCotizador: true,
activoEnAlta: true,
}),
})
expect(ficha.clave).toBe('SENDA')
})
})
describe('listarDesarrollos', () => {
it('cuenta unidades totales/disponibles y expone tieneReglas', async () => {
prismaMock.desarrollo.findMany.mockResolvedValue([
filaDesarrollo({ reglas: { desarrolloId: 'd-1', parametrosJson: JSON.stringify(REGLAS_VALIDAS), updatedAt: new Date(), updatedPorId: null } }),
filaDesarrollo({ id: 'd-2', clave: 'SOLE', nombre: 'Sole' }),
])
prismaMock.unidad.findMany.mockResolvedValue([
{ desarrollo: 'SENDA', status: 'DISPONIBLE' },
{ desarrollo: 'SENDA', status: 'VENDIDO' },
{ desarrollo: 'SOLE', status: 'disponible' },
])
const { desarrollos } = await listarDesarrollos()
expect(desarrollos[0]).toMatchObject({ clave: 'SENDA', unidadesTotales: 2, unidadesDisponibles: 1, tieneReglas: true })
expect(desarrollos[1]).toMatchObject({ clave: 'SOLE', unidadesTotales: 1, unidadesDisponibles: 1, tieneReglas: false })
})
})
describe('actualizarDesarrollo', () => {
it('404 si el desarrollo no existe', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue(null)
await expect(actualizarDesarrollo('nope', { nombre: 'X' })).rejects.toMatchObject({ status: 404 })
})
it('no deja cambiar la clave si tiene unidades', async () => {
prismaMock.desarrollo.findUnique
.mockResolvedValueOnce({ id: 'd-1', clave: 'SENDA' })
.mockResolvedValueOnce(null) // clave destino libre
prismaMock.unidad.count.mockResolvedValue(4)
await expect(actualizarDesarrollo('d-1', { clave: 'SENDA2' })).rejects.toMatchObject({ status: 409 })
expect(prismaMock.desarrollo.update).not.toHaveBeenCalled()
})
})
// ── Borrado bloqueado ─────────────────────────────────────────────────
describe('eliminarDesarrollo', () => {
it('409 con el mensaje acordado si tiene inventario y operación', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.unidad.findMany.mockResolvedValue([{ codigo: 'SENDA-1' }, { codigo: 'SENDA-2' }])
prismaMock.cotizacion.count.mockResolvedValue(7)
await expect(eliminarDesarrollo('d-1')).rejects.toMatchObject({
status: 409,
message: 'El desarrollo tiene 2 unidades y 7 registros de operación; desactívalo en vez de borrarlo',
})
expect(prismaMock.desarrollo.delete).not.toHaveBeenCalled()
})
it('borra si no hay inventario ni operación', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
await eliminarDesarrollo('d-1')
expect(prismaMock.desarrollo.delete).toHaveBeenCalledWith({ where: { id: 'd-1' } })
})
})
// ── Inventario ────────────────────────────────────────────────────────
describe('crearUnidades — generador', () => {
it('genera una unidad por número del rango con {clave} y {n}', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
const resultado = await crearUnidades('d-1', {
generar: { patron: '{clave}-401-{n}', desde: 1, hasta: 3, precioLista: 2500000, supConstruccion: 88 },
})
expect(resultado.creadas).toBe(3)
expect(resultado.unidades.map((u) => u.codigo)).toEqual(['SENDA-401-1', 'SENDA-401-2', 'SENDA-401-3'])
expect(prismaMock.unidad.create).toHaveBeenCalledTimes(3)
expect(prismaMock.unidad.create).toHaveBeenCalledWith({
data: expect.objectContaining({
codigo: 'SENDA-401-1',
desarrollo: 'SENDA',
status: 'DISPONIBLE',
precioLista: 2500000,
supConstruccion: 88,
precioAcabados: 0,
}),
})
})
it('el esquema rechaza un patrón sin {n} y un body con ambos modos', () => {
expect(() => crearUnidadesSchema.parse({ generar: { patron: 'SENDA-401', desde: 1, hasta: 3, precioLista: 1 } })).toThrow()
expect(() =>
crearUnidadesSchema.parse({ unidades: [{ precioLista: 1 }], generar: { patron: '{n}', desde: 1, hasta: 1, precioLista: 1 } }),
).toThrow()
expect(() => crearUnidadesSchema.parse({})).toThrow()
})
it('rechaza un rango invertido', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
await expect(
crearUnidades('d-1', { generar: { patron: '{clave}-{n}', desde: 5, hasta: 1, precioLista: 1 } }),
).rejects.toMatchObject({ status: 400 })
})
it('resuelve el modelo por id validando que sea del desarrollo', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.modeloDesarrollo.findUnique.mockResolvedValue({ id: 'm-1', desarrolloId: 'd-1', nombre: 'HIMALAYA' })
const resultado = await crearUnidades('d-1', {
generar: { patron: '{clave}-{n}', desde: 1, hasta: 1, modeloId: 'm-1', precioLista: 1 },
})
expect(resultado.unidades[0]?.modelo).toBe('HIMALAYA')
})
it('rechaza un modelo de otro desarrollo', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.modeloDesarrollo.findUnique.mockResolvedValue({ id: 'm-9', desarrolloId: 'd-9', nombre: 'AJENO' })
await expect(
crearUnidades('d-1', { generar: { patron: '{clave}-{n}', desde: 1, hasta: 1, modeloId: 'm-9', precioLista: 1 } }),
).rejects.toMatchObject({ status: 400 })
})
})
describe('crearUnidades — alta manual y duplicados', () => {
it('arma el código con manzana y lote cuando no se envía', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
const resultado = await crearUnidades('d-1', {
unidades: [{ manzana: '4', lote: '12', precioLista: 100, precioAcabados: 120 }],
})
expect(resultado.unidades[0]?.codigo).toBe('SENDA-4-12')
})
it('409 y no crea nada si un código ya existe en la BD', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.unidad.findMany
.mockResolvedValueOnce([]) // codigos del desarrollo (correlativo)
.mockResolvedValueOnce([{ codigo: 'SENDA-9' }]) // ya existe
await expect(
crearUnidades('d-1', { unidades: [{ codigo: 'SENDA-9', precioLista: 1 }] }),
).rejects.toMatchObject({ status: 409, message: 'Códigos de unidad duplicados: SENDA-9' })
expect(prismaMock.unidad.create).not.toHaveBeenCalled()
expect(prismaMock.$transaction).not.toHaveBeenCalled()
})
it('409 si el lote repetido aparece dos veces en la misma alta', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.unidad.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([])
await expect(
crearUnidades('d-1', {
unidades: [
{ codigo: 'SENDA-7', precioLista: 1 },
{ codigo: 'SENDA-7', precioLista: 2 },
],
}),
).rejects.toMatchObject({ status: 409 })
})
})
describe('eliminarUnidad', () => {
it('409 si la unidad tiene cotizaciones o ventas', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad())
prismaMock.cotizacion.count.mockResolvedValue(2)
await expect(eliminarUnidad('d-1', 'SENDA-1')).rejects.toMatchObject({ status: 409 })
expect(prismaMock.unidad.delete).not.toHaveBeenCalled()
})
it('404 si la unidad no pertenece al desarrollo', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad({ desarrollo: 'SOLANA' }))
await expect(eliminarUnidad('d-1', 'SENDA-1')).rejects.toMatchObject({ status: 404 })
})
it('borra una unidad libre', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1', clave: 'SENDA' })
prismaMock.unidad.findUnique.mockResolvedValue(filaUnidad())
await eliminarUnidad('d-1', 'SENDA-1')
expect(prismaMock.unidad.delete).toHaveBeenCalledWith({ where: { codigo: 'SENDA-1' } })
})
})
// ── Modelos ───────────────────────────────────────────────────────────
describe('eliminarModelo', () => {
it('409 si el modelo tiene unidades', async () => {
prismaMock.modeloDesarrollo.findUnique.mockResolvedValue({
id: 'm-1',
nombre: 'HIMALAYA',
desarrollo: { clave: 'SENDA' },
})
prismaMock.unidad.count.mockResolvedValue(79)
await expect(eliminarModelo('m-1')).rejects.toMatchObject({ status: 409 })
expect(prismaMock.modeloDesarrollo.delete).not.toHaveBeenCalled()
})
it('borra el modelo sin unidades', async () => {
prismaMock.modeloDesarrollo.findUnique.mockResolvedValue({
id: 'm-1',
nombre: 'NUEVO',
desarrollo: { clave: 'SENDA' },
})
await eliminarModelo('m-1')
expect(prismaMock.modeloDesarrollo.delete).toHaveBeenCalledWith({ where: { id: 'm-1' } })
})
})
describe('agregarModelo', () => {
it('409 si el nombre ya existe en el desarrollo', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1' })
prismaMock.modeloDesarrollo.findUnique.mockResolvedValue({ id: 'm-1' })
await expect(agregarModelo('d-1', { nombre: 'HIMALAYA' })).rejects.toMatchObject({ status: 409 })
})
})
// ── Reglas del cotizador ──────────────────────────────────────────────
describe('parametrosDesarrolloSchema', () => {
it('acepta la forma real del cotizador', () => {
expect(parametrosDesarrolloSchema.parse(REGLAS_VALIDAS)).toMatchObject({ precioUnico: true })
})
it('rechaza porcentajes fuera de 0..1', () => {
const invalido = {
...REGLAS_VALIDAS,
propuestas: [{ ...REGLAS_VALIDAS.propuestas[0], pctEnganche: 1.5 }],
}
expect(() => parametrosDesarrolloSchema.parse(invalido)).toThrow()
})
it('rechaza enums desconocidos y claves de más', () => {
expect(() => parametrosDesarrolloSchema.parse({ ...REGLAS_VALIDAS, fuenteDeN: 'otra' })).toThrow()
expect(() => parametrosDesarrolloSchema.parse({ ...REGLAS_VALIDAS, propuestas: [{ ...REGLAS_VALIDAS.propuestas[0], modoMensualidades: 'raro' }] })).toThrow()
expect(() => parametrosDesarrolloSchema.parse({ ...REGLAS_VALIDAS, extra: 1 })).toThrow()
expect(() => parametrosDesarrolloSchema.parse({ ...REGLAS_VALIDAS, propuestas: [{ ...REGLAS_VALIDAS.propuestas[0], descuentoDefault: -1 }] })).toThrow()
})
})
describe('guardarReglas', () => {
it('hace upsert del JSON validado y devuelve el objeto parseado', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue({ id: 'd-1' })
prismaMock.reglaCotizadorDesarrollo.upsert.mockResolvedValue({
desarrolloId: 'd-1',
parametrosJson: JSON.stringify(REGLAS_VALIDAS),
updatedAt: new Date('2026-09-21T00:00:00.000Z'),
updatedPorId: 'u-admin',
})
const guardadas = await guardarReglas('d-1', REGLAS_VALIDAS, 'u-admin')
expect(prismaMock.reglaCotizadorDesarrollo.upsert).toHaveBeenCalledWith({
where: { desarrolloId: 'd-1' },
update: { parametrosJson: JSON.stringify(REGLAS_VALIDAS), updatedPorId: 'u-admin' },
create: { desarrolloId: 'd-1', parametrosJson: JSON.stringify(REGLAS_VALIDAS), updatedPorId: 'u-admin' },
})
expect(guardadas.parametros.precioUnico).toBe(true)
expect(guardadas.updatedPorId).toBe('u-admin')
})
it('404 si el desarrollo no existe', async () => {
prismaMock.desarrollo.findUnique.mockResolvedValue(null)
await expect(guardarReglas('nope', REGLAS_VALIDAS, null)).rejects.toMatchObject({ status: 404 })
})
})
// ── Público ───────────────────────────────────────────────────────────
describe('listarDesarrollosPublicos', () => {
it('solo publica activos no cerrados, con inventario disponible y sin datos personales', async () => {
prismaMock.desarrollo.findMany.mockResolvedValue([
filaDesarrollo({
modelos: [
{ id: 'm-1', desarrolloId: 'd-1', nombre: 'HIMALAYA', recamaras: 3, banos: 2.5, estacionamientos: 2, supConstruccion: 120, activo: true },
],
}),
])
prismaMock.unidad.findMany.mockResolvedValue([
filaUnidad({ codigo: 'SENDA-1', nivel: '4' }),
filaUnidad({ codigo: 'SENDA-2', status: 'VENDIDO', precioLista: 999 }),
])
const { desarrollos } = await listarDesarrollosPublicos()
const d = desarrollos[0]
expect(prismaMock.desarrollo.findMany).toHaveBeenCalledWith({
where: { activoEnCotizador: true, estado: { not: 'CERRADO' } },
include: expect.anything(),
orderBy: { clave: 'asc' },
})
expect(d?.unidadesDisponibles).toEqual([
{
codigo: 'SENDA-1',
modelo: 'HIMALAYA',
nivel: '4',
precioLista: 3000000,
precioAcabados: 3200000,
supConstruccion: 120,
},
])
expect(d?.modelos[0]).toMatchObject({ nombre: 'HIMALAYA', disponibles: 1, precioDesde: 3000000 })
// Nunca consulta tablas con datos de clientes y la ficha no trae esos campos
expect(prismaMock.cliente.count).not.toHaveBeenCalled()
expect(prismaMock.cotizacion.count).not.toHaveBeenCalled()
const json = JSON.stringify(desarrollos)
for (const prohibido of ['cliente', 'email', 'telefono', 'curp', 'vendedor', 'apartado']) {
expect(json.toLowerCase()).not.toContain(prohibido)
}
})
it('404 si la clave no existe o no es pública', async () => {
prismaMock.desarrollo.findFirst.mockResolvedValue(null)
await expect(obtenerDesarrolloPublico('NOEXISTE')).rejects.toMatchObject({ status: 404 })
})
})

View File

@@ -0,0 +1,754 @@
import { z } from 'zod'
import { prisma } from './prisma.js'
import { AppError, BadRequestError, NotFoundError } from '../utils/errors.js'
/**
* Módulo Desarrollos: la BD es la fuente de verdad del catálogo de
* desarrollos (ficha comercial, modelos de unidad y reglas del cotizador).
*
* Sustituye a las listas fijas del código (DESARROLLOS de
* config/campos-departamento.ts y las constantes de
* apps/cotizador/src/data/desarrollos.ts), que hoy siguen funcionando como
* espejo hasta que el resto de los frentes migre a esta tabla.
*
* El inventario sigue viviendo en `Unidad`, ligado por `desarrollo` = `clave`
* del desarrollo (el `codigo` de la unidad se forma con esa clave).
*/
/** Estados comerciales del desarrollo */
export const ESTADOS_DESARROLLO = ['ACTIVO', 'PREVENTA', 'CERRADO'] as const
/** VERTICAL = edificio (deptos); HORIZONTAL = casas en lote */
export const TIPOS_DESARROLLO = ['VERTICAL', 'HORIZONTAL'] as const
/** Formatos de propuesta del cotizador */
export const TIPOS_PROPUESTA = [
'CREDITO_BANCARIO',
'CREDITO_INFONAVIT_COFINAVIT',
'CONTADO',
'SEMI_CONTADO',
] as const
/** Claves de descuento editables del cotizador */
export const CLAVES_DESCUENTO = ['creditoBancario', 'creditoInfonavit', 'contado', 'semiContado'] as const
const CLAVE_DESARROLLO = /^[A-Z0-9-]{2,20}$/
// ── Validación de las reglas del cotizador ────────────────────────────
const fraccion = (campo: string) =>
z.number().min(0, `${campo} debe estar entre 0 y 1`).max(1, `${campo} debe estar entre 0 y 1`)
const propuestaSchema = z
.object({
tipo: z.enum(TIPOS_PROPUESTA),
claveDescuento: z.enum(CLAVES_DESCUENTO),
titulo: z.string().trim().min(1),
descuentoDefault: fraccion('descuentoDefault'),
pctEnganche: fraccion('pctEnganche'),
engancheBase: z.enum(['lista', 'final']),
pctPagoFinal: fraccion('pctPagoFinal'),
pagoFinalBase: z.enum(['lista', 'final']),
// 'porcentaje' es el modo de la mayoría de los desarrollos; sin él la
// config real del cotizador no valida (el brief lo omitía por error).
modoMensualidades: z.enum(['remanente', 'remanenteLista', 'porcentaje', 'ninguna']),
// SOLANA Infonavit: si el enganche es 0 y no hay apartado, no se resta
engancheCeroSinApartado: z.boolean().optional(),
})
.strict()
/**
* Forma del JSON de reglas: la config que antes vivía en
* apps/cotizador/src/data/desarrollos.ts. `id`/`nombre` se aceptan porque los
* objetos del cotizador los traen, pero la fuente de verdad del nombre es la
* ficha del desarrollo.
*/
export const parametrosDesarrolloSchema = z
.object({
id: z.string().trim().min(1).optional(),
nombre: z.string().trim().min(1).optional(),
precioUnico: z.boolean(),
fuenteDeN: z.enum(['fechaEntrega', 'fijo', 'porUnidad']),
// N fijo cuando fuenteDeN === 'fijo' (entrega inmediata)
nFijo: z.number().int().min(0).optional(),
nDefault: z.number().int().min(0),
// null => el cotizador muestra "PENDIENTE" (desarrollos ya entregados)
cuotaMantenimiento: z.number().min(0).nullable(),
prefijoUnidadPdf: z.string().trim().min(1),
propuestas: z.array(propuestaSchema).min(1, 'Se requiere al menos una propuesta'),
})
.strict()
export type ParametrosDesarrolloJson = z.infer<typeof parametrosDesarrolloSchema>
// ── Validación de la ficha ────────────────────────────────────────────
export const crearDesarrolloSchema = z
.object({
clave: z
.string()
.trim()
.regex(CLAVE_DESARROLLO, 'La clave debe ser mayúsculas sin espacios (2 a 20 caracteres: A-Z, 0-9, -)'),
nombre: z.string().trim().min(1, 'El nombre es obligatorio'),
estado: z.enum(ESTADOS_DESARROLLO).optional(),
tipo: z.enum(TIPOS_DESARROLLO).optional(),
constructora: z.string().trim().min(1).nullable().optional(),
direccion: z.string().trim().min(1).nullable().optional(),
zona: z.string().trim().min(1).nullable().optional(),
descripcion: z.string().trim().min(1).nullable().optional(),
amenidades: z.string().trim().min(1).nullable().optional(),
fechaInicioObra: z.coerce.date().nullable().optional(),
fechaEntregaProgramada: z.coerce.date().nullable().optional(),
fechaEntregaReprogramada: z.coerce.date().nullable().optional(),
activoEnCotizador: z.boolean().optional(),
activoEnAlta: z.boolean().optional(),
prefijoUnidadPdf: z.string().trim().min(1).nullable().optional(),
imagenUrl: z.string().trim().min(1).nullable().optional(),
logoUrl: z.string().trim().min(1).nullable().optional(),
})
.strict()
export const actualizarDesarrolloSchema = crearDesarrolloSchema.partial()
export const crearModeloSchema = z
.object({
nombre: z.string().trim().min(1, 'El nombre del modelo es obligatorio'),
recamaras: z.number().int().min(0).nullable().optional(),
banos: z.number().min(0).nullable().optional(),
estacionamientos: z.number().int().min(0).nullable().optional(),
supConstruccion: z.number().min(0).nullable().optional(),
activo: z.boolean().optional(),
})
.strict()
export const actualizarModeloSchema = crearModeloSchema.partial()
const unidadManualSchema = z
.object({
codigo: z.string().trim().min(1).optional(),
manzana: z.string().trim().optional(),
lote: z.string().trim().optional(),
nivel: z.string().trim().optional(),
modeloId: z.string().trim().min(1).optional(),
precioLista: z.number().min(0, 'precioLista es obligatorio'),
precioAcabados: z.number().min(0).optional(),
supConstruccion: z.number().min(0).optional(),
supExcedente: z.number().min(0).optional(),
supEstacionamiento: z.number().min(0).optional(),
})
.strict()
/** Tope del generador: un rango más grande huele a error de captura */
export const MAX_UNIDADES_POR_ALTA = 1000
const generarUnidadesSchema = z
.object({
patron: z
.string()
.trim()
.min(1)
.refine((p) => p.includes('{n}'), 'El patrón debe incluir {n} (el número de la unidad)')
// El código final sigue el formato de la clave: mayúsculas sin espacios
.refine((p) => !/[^A-Za-z0-9{}_-]/.test(p), 'El patrón solo admite letras, números, guiones, {clave} y {n}'),
desde: z.number().int(),
hasta: z.number().int(),
modeloId: z.string().trim().min(1).optional(),
precioLista: z.number().min(0),
precioAcabados: z.number().min(0).optional(),
supConstruccion: z.number().min(0).optional(),
})
.strict()
export const crearUnidadesSchema = z
.object({
unidades: z.array(unidadManualSchema).min(1).max(MAX_UNIDADES_POR_ALTA).optional(),
generar: generarUnidadesSchema.optional(),
})
.strict()
.refine((b) => (b.unidades === undefined) !== (b.generar === undefined), {
message: 'Envía `unidades` (alta manual) o `generar` (rango), pero no ambos',
})
export type CrearUnidadesBody = z.infer<typeof crearUnidadesSchema>
// ── Helpers ───────────────────────────────────────────────────────────
interface ModeloFila {
id: string
nombre: string
recamaras: number | null
banos: number | null
estacionamientos: number | null
supConstruccion: number | null
activo: boolean
}
function modeloDto(m: ModeloFila) {
return {
id: m.id,
nombre: m.nombre,
recamaras: m.recamaras,
banos: m.banos,
estacionamientos: m.estacionamientos,
supConstruccion: m.supConstruccion,
activo: m.activo,
}
}
function esDisponible(status: string): boolean {
return status.trim().toUpperCase() === 'DISPONIBLE'
}
/** Clave de desarrollo normalizada (la tabla Unidad la guarda en mayúsculas) */
export function normalizarClave(clave: string): string {
return clave.trim().toUpperCase()
}
/** Conteos por desarrollo a partir de una lectura del inventario */
function conteosPorDesarrollo(unidades: { desarrollo: string; status: string }[]): Map<string, { total: number; disponibles: number }> {
const mapa = new Map<string, { total: number; disponibles: number }>()
for (const u of unidades) {
const clave = normalizarClave(u.desarrollo)
const actual = mapa.get(clave) ?? { total: 0, disponibles: 0 }
actual.total++
if (esDisponible(u.status)) actual.disponibles++
mapa.set(clave, actual)
}
return mapa
}
function parsearReglas(regla: { parametrosJson: string; updatedAt: Date; updatedPorId: string | null } | null) {
if (regla === null) return null
let parametros: unknown
try {
parametros = JSON.parse(regla.parametrosJson)
} catch {
parametros = null
}
return {
parametros,
updatedAt: regla.updatedAt,
updatedPorId: regla.updatedPorId,
}
}
/** Desarrollo + relaciones tal como lo devuelve Prisma en las lecturas de ficha */
const INCLUDE_FICHA = { modelos: { orderBy: { nombre: 'asc' } }, reglas: true } as const
async function fichaPorId(id: string) {
const fila = await prisma.desarrollo.findUnique({ where: { id }, include: INCLUDE_FICHA })
if (fila === null) return null
const unidades = await prisma.unidad.findMany({
where: { desarrollo: fila.clave },
select: { status: true },
})
const porEstatus: Record<string, number> = {}
for (const u of unidades) {
const estatus = u.status.trim().toUpperCase() || 'SIN_ESTATUS'
porEstatus[estatus] = (porEstatus[estatus] ?? 0) + 1
}
return {
id: fila.id,
clave: fila.clave,
nombre: fila.nombre,
estado: fila.estado,
tipo: fila.tipo,
constructora: fila.constructora,
direccion: fila.direccion,
zona: fila.zona,
descripcion: fila.descripcion,
amenidades: fila.amenidades,
fechaInicioObra: fila.fechaInicioObra,
fechaEntregaProgramada: fila.fechaEntregaProgramada,
fechaEntregaReprogramada: fila.fechaEntregaReprogramada,
activoEnCotizador: fila.activoEnCotizador,
activoEnAlta: fila.activoEnAlta,
prefijoUnidadPdf: fila.prefijoUnidadPdf,
imagenUrl: fila.imagenUrl,
logoUrl: fila.logoUrl,
createdAt: fila.createdAt,
updatedAt: fila.updatedAt,
modelos: fila.modelos.map(modeloDto),
reglas: parsearReglas(fila.reglas),
unidadesTotales: unidades.length,
unidadesDisponibles: unidades.filter((u) => esDisponible(u.status)).length,
unidadesPorEstatus: porEstatus,
}
}
export type FichaDesarrollo = NonNullable<Awaited<ReturnType<typeof fichaPorId>>>
/** La ficha completa o 404 */
export async function obtenerDesarrollo(id: string): Promise<FichaDesarrollo> {
const ficha = await fichaPorId(id)
if (ficha === null) throw new NotFoundError('Desarrollo no encontrado')
return ficha
}
// ── Listado ───────────────────────────────────────────────────────────
export async function listarDesarrollos() {
const [desarrollos, unidades] = await Promise.all([
prisma.desarrollo.findMany({ include: INCLUDE_FICHA, orderBy: { clave: 'asc' } }),
prisma.unidad.findMany({ select: { desarrollo: true, status: true } }),
])
const conteos = conteosPorDesarrollo(unidades)
return {
desarrollos: desarrollos.map((d) => {
const c = conteos.get(normalizarClave(d.clave)) ?? { total: 0, disponibles: 0 }
return {
id: d.id,
clave: d.clave,
nombre: d.nombre,
estado: d.estado,
tipo: d.tipo,
activoEnCotizador: d.activoEnCotizador,
activoEnAlta: d.activoEnAlta,
modelos: d.modelos.map(modeloDto),
unidadesTotales: c.total,
unidadesDisponibles: c.disponibles,
tieneReglas: d.reglas !== null,
}
}),
}
}
// ── Alta / cambio / borrado ───────────────────────────────────────────
export async function crearDesarrollo(data: z.infer<typeof crearDesarrolloSchema>) {
const clave = normalizarClave(data.clave)
const existente = await prisma.desarrollo.findUnique({ where: { clave }, select: { id: true } })
if (existente !== null) throw new AppError(409, `Ya existe un desarrollo con la clave ${clave}`)
const fila = await prisma.desarrollo.create({
data: {
clave,
nombre: data.nombre,
estado: data.estado ?? 'ACTIVO',
tipo: data.tipo ?? 'VERTICAL',
constructora: data.constructora ?? null,
direccion: data.direccion ?? null,
zona: data.zona ?? null,
descripcion: data.descripcion ?? null,
amenidades: data.amenidades ?? null,
fechaInicioObra: data.fechaInicioObra ?? null,
fechaEntregaProgramada: data.fechaEntregaProgramada ?? null,
fechaEntregaReprogramada: data.fechaEntregaReprogramada ?? null,
activoEnCotizador: data.activoEnCotizador ?? true,
activoEnAlta: data.activoEnAlta ?? true,
prefijoUnidadPdf: data.prefijoUnidadPdf ?? null,
imagenUrl: data.imagenUrl ?? null,
logoUrl: data.logoUrl ?? null,
},
})
return obtenerDesarrollo(fila.id)
}
export async function actualizarDesarrollo(id: string, data: z.infer<typeof actualizarDesarrolloSchema>) {
const actual = await prisma.desarrollo.findUnique({ where: { id }, select: { id: true, clave: true } })
if (actual === null) throw new NotFoundError('Desarrollo no encontrado')
// Solo se actualizan los campos enviados (los undefined se descartan)
const cambios: Record<string, unknown> = {}
for (const [campo, valor] of Object.entries(data)) {
if (valor !== undefined) cambios[campo] = valor
}
if (data.clave !== undefined) {
const clave = normalizarClave(data.clave)
if (clave !== actual.clave) {
const ocupada = await prisma.desarrollo.findUnique({ where: { clave }, select: { id: true } })
if (ocupada !== null) throw new AppError(409, `Ya existe un desarrollo con la clave ${clave}`)
// El inventario se liga por clave: renombrar la clave sin arrastrar las
// unidades las dejaría huérfanas, así que solo se permite sin unidades.
const unidades = await prisma.unidad.count({ where: { desarrollo: actual.clave } })
if (unidades > 0) {
throw new AppError(409, `El desarrollo tiene ${unidades} unidades; no se puede cambiar la clave`)
}
}
cambios['clave'] = clave
}
await prisma.desarrollo.update({ where: { id }, data: cambios as never })
return obtenerDesarrollo(id)
}
/** Registros de operación ligados al desarrollo: impiden borrarlo */
async function contarRegistrosDeOperacion(clave: string, codigos: string[]): Promise<number> {
const porUnidad = codigos.length > 0 ? { unidadCodigo: { in: codigos } } : null
const [cotizaciones, leads, clientes, planes] = await Promise.all([
prisma.cotizacion.count({ where: porUnidad === null ? { desarrollo: clave } : { OR: [{ desarrollo: clave }, porUnidad] } }),
prisma.leadCaptura.count({ where: { desarrollo: clave } }),
prisma.cliente.count({ where: { proyecto: clave } }),
prisma.planPago.count({ where: { proyecto: clave } }),
])
return cotizaciones + leads + clientes + planes
}
export async function eliminarDesarrollo(id: string): Promise<void> {
const actual = await prisma.desarrollo.findUnique({ where: { id }, select: { id: true, clave: true } })
if (actual === null) throw new NotFoundError('Desarrollo no encontrado')
const unidades = await prisma.unidad.findMany({ where: { desarrollo: actual.clave }, select: { codigo: true } })
const operacion = await contarRegistrosDeOperacion(
actual.clave,
unidades.map((u) => u.codigo),
)
if (unidades.length > 0 || operacion > 0) {
throw new AppError(
409,
`El desarrollo tiene ${unidades.length} unidades y ${operacion} registros de operación; desactívalo en vez de borrarlo`,
)
}
await prisma.desarrollo.delete({ where: { id } })
}
// ── Modelos ───────────────────────────────────────────────────────────
export async function agregarModelo(desarrolloId: string, data: z.infer<typeof crearModeloSchema>) {
const desarrollo = await prisma.desarrollo.findUnique({ where: { id: desarrolloId }, select: { id: true } })
if (desarrollo === null) throw new NotFoundError('Desarrollo no encontrado')
const repetido = await prisma.modeloDesarrollo.findUnique({
where: { desarrolloId_nombre: { desarrolloId, nombre: data.nombre } },
select: { id: true },
})
if (repetido !== null) throw new AppError(409, `El modelo ${data.nombre} ya existe en el desarrollo`)
return prisma.modeloDesarrollo.create({
data: {
desarrolloId,
nombre: data.nombre,
recamaras: data.recamaras ?? null,
banos: data.banos ?? null,
estacionamientos: data.estacionamientos ?? null,
supConstruccion: data.supConstruccion ?? null,
activo: data.activo ?? true,
},
})
}
export async function actualizarModelo(modeloId: string, data: z.infer<typeof actualizarModeloSchema>) {
const actual = await prisma.modeloDesarrollo.findUnique({ where: { id: modeloId } })
if (actual === null) throw new NotFoundError('Modelo no encontrado')
if (data.nombre !== undefined && data.nombre !== actual.nombre) {
const repetido = await prisma.modeloDesarrollo.findUnique({
where: { desarrolloId_nombre: { desarrolloId: actual.desarrolloId, nombre: data.nombre } },
select: { id: true },
})
if (repetido !== null) throw new AppError(409, `El modelo ${data.nombre} ya existe en el desarrollo`)
}
const cambios: Record<string, unknown> = {}
for (const [campo, valor] of Object.entries(data)) {
if (valor !== undefined) cambios[campo] = valor
}
return prisma.modeloDesarrollo.update({ where: { id: modeloId }, data: cambios as never })
}
export async function eliminarModelo(modeloId: string): Promise<void> {
const actual = await prisma.modeloDesarrollo.findUnique({
where: { id: modeloId },
include: { desarrollo: { select: { clave: true } } },
})
if (actual === null) throw new NotFoundError('Modelo no encontrado')
const unidades = await prisma.unidad.count({
where: { desarrollo: actual.desarrollo.clave, modelo: actual.nombre },
})
if (unidades > 0) {
throw new AppError(409, `El modelo ${actual.nombre} tiene ${unidades} unidades; no se puede eliminar`)
}
await prisma.modeloDesarrollo.delete({ where: { id: modeloId } })
}
// ── Reglas del cotizador ──────────────────────────────────────────────
export async function guardarReglas(desarrolloId: string, parametros: ParametrosDesarrolloJson, usuarioId: string | null) {
const desarrollo = await prisma.desarrollo.findUnique({ where: { id: desarrolloId }, select: { id: true } })
if (desarrollo === null) throw new NotFoundError('Desarrollo no encontrado')
const parametrosJson = JSON.stringify(parametros)
const regla = await prisma.reglaCotizadorDesarrollo.upsert({
where: { desarrolloId },
update: { parametrosJson, updatedPorId: usuarioId },
create: { desarrolloId, parametrosJson, updatedPorId: usuarioId },
})
return {
desarrolloId: regla.desarrolloId,
parametros: JSON.parse(regla.parametrosJson) as ParametrosDesarrolloJson,
updatedAt: regla.updatedAt,
updatedPorId: regla.updatedPorId,
}
}
// ── Inventario ────────────────────────────────────────────────────────
interface UnidadNueva {
codigo: string
modelo: string
manzana: string
lote: string
nivel: string
precioLista: number
precioAcabados: number
supConstruccion: number
supExcedente: number
supEstacionamiento: number
status: string
}
/** Modelo del desarrollo por id (valida que pertenezca al desarrollo) */
async function nombreModelo(desarrolloId: string, modeloId: string): Promise<string> {
const modelo = await prisma.modeloDesarrollo.findUnique({ where: { id: modeloId } })
if (modelo === null || modelo.desarrolloId !== desarrolloId) {
throw new BadRequestError('El modelo no pertenece a este desarrollo')
}
return modelo.nombre
}
/** Correlativo siguiente de `${clave}-n` en el inventario del desarrollo */
function correlativoDesdeCodigos(clave: string, codigos: string[]): number {
const patron = new RegExp(`^${clave}-(\\d+)$`)
let max = 0
for (const codigo of codigos) {
const m = patron.exec(codigo)
if (m === null) continue
const n = Number(m[1])
if (n > max) max = n
}
return max
}
export async function crearUnidades(
desarrolloId: string,
body: CrearUnidadesBody,
): Promise<{ creadas: number; unidades: UnidadNueva[] }> {
const desarrollo = await prisma.desarrollo.findUnique({ where: { id: desarrolloId }, select: { id: true, clave: true } })
if (desarrollo === null) throw new NotFoundError('Desarrollo no encontrado')
const clave = desarrollo.clave
let filas: UnidadNueva[]
if (body.generar !== undefined) {
const g = body.generar
if (g.hasta < g.desde) throw new BadRequestError('El rango es inválido: `hasta` debe ser mayor o igual a `desde`')
if (g.hasta - g.desde + 1 > MAX_UNIDADES_POR_ALTA) {
throw new BadRequestError(`El rango genera más de ${MAX_UNIDADES_POR_ALTA} unidades`)
}
const modelo = g.modeloId !== undefined ? await nombreModelo(desarrolloId, g.modeloId) : ''
filas = []
for (let n = g.desde; n <= g.hasta; n++) {
const codigo = g.patron.replaceAll('{clave}', clave).replaceAll('{n}', String(n))
filas.push({
codigo,
modelo,
manzana: '',
lote: '',
nivel: '',
precioLista: g.precioLista,
precioAcabados: g.precioAcabados ?? 0,
supConstruccion: g.supConstruccion ?? 0,
supExcedente: 0,
supEstacionamiento: 0,
status: 'DISPONIBLE',
})
}
} else {
const manuales = body.unidades ?? []
const existentes = await prisma.unidad.findMany({ where: { desarrollo: clave }, select: { codigo: true } })
let correlativo = correlativoDesdeCodigos(clave, existentes.map((u) => u.codigo))
filas = []
for (const u of manuales) {
const modelo = u.modeloId !== undefined ? await nombreModelo(desarrolloId, u.modeloId) : ''
// Sin código explícito se arma con manzana/lote o con un correlativo
const codigo =
u.codigo ??
(u.manzana !== undefined && u.manzana !== '' && u.lote !== undefined && u.lote !== ''
? `${clave}-${u.manzana}-${u.lote}`
: `${clave}-${++correlativo}`)
filas.push({
codigo,
modelo,
manzana: u.manzana ?? '',
lote: u.lote ?? '',
nivel: u.nivel ?? '',
precioLista: u.precioLista,
precioAcabados: u.precioAcabados ?? 0,
supConstruccion: u.supConstruccion ?? 0,
supExcedente: u.supExcedente ?? 0,
supEstacionamiento: u.supEstacionamiento ?? 0,
status: 'DISPONIBLE',
})
}
}
const codigos = filas.map((f) => f.codigo)
const repetidosEnLote = [...new Set(codigos.filter((c, i) => codigos.indexOf(c) !== i))]
const yaEnBd =
codigos.length > 0
? (await prisma.unidad.findMany({ where: { codigo: { in: codigos } }, select: { codigo: true } })).map((u) => u.codigo)
: []
const duplicados = [...new Set([...repetidosEnLote, ...yaEnBd])]
if (duplicados.length > 0) {
throw new AppError(409, `Códigos de unidad duplicados: ${duplicados.join(', ')}`)
}
const creadas = await prisma.$transaction(filas.map((f) => prisma.unidad.create({ data: { ...f, desarrollo: clave } })))
return {
creadas: creadas.length,
unidades: creadas.map((u) => ({
codigo: u.codigo,
modelo: u.modelo,
manzana: u.manzana,
lote: u.lote,
nivel: u.nivel,
precioLista: u.precioLista,
precioAcabados: u.precioAcabados,
supConstruccion: u.supConstruccion,
supExcedente: u.supExcedente,
supEstacionamiento: u.supEstacionamiento,
status: u.status,
})),
}
}
export async function eliminarUnidad(desarrolloId: string, codigo: string): Promise<void> {
const desarrollo = await prisma.desarrollo.findUnique({ where: { id: desarrolloId }, select: { clave: true } })
if (desarrollo === null) throw new NotFoundError('Desarrollo no encontrado')
const unidad = await prisma.unidad.findUnique({ where: { codigo } })
if (unidad === null || normalizarClave(unidad.desarrollo) !== normalizarClave(desarrollo.clave)) {
throw new NotFoundError(`Unidad ${codigo} no encontrada en el desarrollo`)
}
const [cotizaciones, planes, clientes] = await Promise.all([
prisma.cotizacion.count({ where: { unidadCodigo: codigo } }),
prisma.planPago.count({ where: { unidadCodigo: codigo } }),
prisma.cliente.count({ where: { unidadCodigo: codigo } }),
])
const operacion = cotizaciones + planes + clientes
if (operacion > 0) {
throw new AppError(409, `La unidad ${codigo} tiene ${operacion} cotizaciones o ventas; no se puede eliminar`)
}
await prisma.unidad.delete({ where: { codigo } })
}
// ── Público (micrositio, sin auth) ────────────────────────────────────
/**
* Ficha pública: SOLO información comercial del desarrollo y su inventario
* disponible. Nunca clientes, cotizaciones ni datos personales.
*/
function fichaPublica(
desarrollo: {
clave: string
nombre: string
estado: string
tipo: string
zona: string | null
direccion: string | null
descripcion: string | null
amenidades: string | null
imagenUrl: string | null
logoUrl: string | null
fechaEntregaProgramada: Date | null
modelos: { nombre: string; recamaras: number | null; banos: number | null; estacionamientos: number | null; supConstruccion: number | null; activo: boolean }[]
},
unidades: {
codigo: string
modelo: string
nivel: string
precioLista: number
precioAcabados: number
supConstruccion: number
}[],
) {
const disponiblesPorModelo = new Map<string, { n: number; precioDesde: number | null }>()
for (const u of unidades) {
const actual = disponiblesPorModelo.get(u.modelo) ?? { n: 0, precioDesde: null }
actual.n++
if (u.precioLista > 0 && (actual.precioDesde === null || u.precioLista < actual.precioDesde)) {
actual.precioDesde = u.precioLista
}
disponiblesPorModelo.set(u.modelo, actual)
}
return {
clave: desarrollo.clave,
nombre: desarrollo.nombre,
estado: desarrollo.estado,
tipo: desarrollo.tipo,
zona: desarrollo.zona,
direccion: desarrollo.direccion,
descripcion: desarrollo.descripcion,
amenidades: desarrollo.amenidades,
imagenUrl: desarrollo.imagenUrl,
logoUrl: desarrollo.logoUrl,
fechaEntregaProgramada: desarrollo.fechaEntregaProgramada,
modelos: desarrollo.modelos
.filter((m) => m.activo)
.map((m) => {
const disp = disponiblesPorModelo.get(m.nombre) ?? { n: 0, precioDesde: null }
return {
nombre: m.nombre,
recamaras: m.recamaras,
banos: m.banos,
estacionamientos: m.estacionamientos,
supConstruccion: m.supConstruccion,
disponibles: disp.n,
precioDesde: disp.precioDesde,
}
}),
unidadesDisponibles: unidades.map((u) => ({
codigo: u.codigo,
modelo: u.modelo,
precioLista: u.precioLista,
precioAcabados: u.precioAcabados,
supConstruccion: u.supConstruccion,
...(u.nivel !== '' ? { nivel: u.nivel } : {}),
})),
}
}
/** Filtro de publicación: activo en cotizador y no cerrado */
const DONDE_PUBLICO = { activoEnCotizador: true, estado: { not: 'CERRADO' } } as const
/** Inventario publicado de un desarrollo (solo unidades DISPONIBLE) */
async function unidadesPublicas(clave: string) {
const filas = await prisma.unidad.findMany({
where: { desarrollo: clave },
select: { codigo: true, desarrollo: true, modelo: true, nivel: true, precioLista: true, precioAcabados: true, supConstruccion: true, status: true },
orderBy: { codigo: 'asc' },
})
return filas.filter((u) => esDisponible(u.status))
}
export async function listarDesarrollosPublicos() {
const [desarrollos, unidades] = await Promise.all([
prisma.desarrollo.findMany({ where: DONDE_PUBLICO, include: INCLUDE_FICHA, orderBy: { clave: 'asc' } }),
prisma.unidad.findMany({
select: { codigo: true, desarrollo: true, modelo: true, nivel: true, precioLista: true, precioAcabados: true, supConstruccion: true, status: true },
orderBy: { codigo: 'asc' },
}),
])
return {
desarrollos: desarrollos.map((d) =>
fichaPublica(
d,
unidades
.filter((u) => normalizarClave(u.desarrollo) === normalizarClave(d.clave) && esDisponible(u.status))
.map((u) => ({ codigo: u.codigo, modelo: u.modelo, nivel: u.nivel, precioLista: u.precioLista, precioAcabados: u.precioAcabados, supConstruccion: u.supConstruccion })),
),
),
}
}
export async function obtenerDesarrolloPublico(claveCruda: string) {
const clave = normalizarClave(claveCruda)
const fila = await prisma.desarrollo.findFirst({ where: { clave, ...DONDE_PUBLICO }, include: INCLUDE_FICHA })
if (fila === null) throw new NotFoundError('Desarrollo no encontrado')
const unidades = await unidadesPublicas(fila.clave)
return fichaPublica(fila, unidades)
}

View File

@@ -0,0 +1,253 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
// ---------- Prisma mockeado (los tests no tocan BD) ----------
// `reglaCotizadorDesarrollo` y `desarrollo` se activan/desactivan por test:
// simulan el cliente Prisma con los modelos generados o sin ellos (lectura
// cruda de las tablas).
const prismaMock = vi.hoisted(() => ({
$queryRawUnsafe: vi.fn(),
reglaCotizadorDesarrollo: undefined as undefined | { findMany: ReturnType<typeof vi.fn> },
desarrollo: undefined as undefined | { findMany: ReturnType<typeof vi.fn> },
}))
vi.mock('./prisma.js', () => ({ prisma: prismaMock }))
import { DESARROLLOS, type ParametrosDesarrollo } from './cotizacion.service.js'
import {
getReglasDesarrollo,
getReglasEfectivas,
getReglasGuardadas,
normalizarReglas,
} from './reglas-desarrollo.service.js'
/** Reglas con la forma exacta que guarda la BD (JSON serializado) */
function reglasJson(params: ParametrosDesarrollo, extras: Record<string, unknown> = {}): string {
return JSON.stringify({
...params,
...extras,
propuestas: params.propuestas.map((p) => ({ ...p, activa: true })),
})
}
function usarModeloPrisma(): ReturnType<typeof vi.fn> {
const findMany = vi.fn()
prismaMock.reglaCotizadorDesarrollo = { findMany }
return findMany
}
/** Ficha del desarrollo (tabla Desarrollo) para el catálogo del servicio */
function ficha(id: string, clave: string, extras: Record<string, unknown> = {}) {
return {
id,
clave,
nombre: clave.charAt(0) + clave.slice(1).toLowerCase(),
activoEnCotizador: true,
prefijoUnidadPdf: 'CASA',
...extras,
}
}
function usarCatalogo(filas: unknown[]): void {
prismaMock.desarrollo = { findMany: vi.fn().mockResolvedValue(filas) }
}
/** Propuesta del fixture (JSON crudo) por tipo */
function brinda(propuestas: Record<string, unknown>[], tipo: string): Record<string, unknown> {
const p = propuestas.find((x) => x['tipo'] === tipo)
if (!p) throw new Error(`La propuesta ${tipo} no está en el fixture`)
return p
}
beforeEach(() => {
vi.clearAllMocks()
prismaMock.reglaCotizadorDesarrollo = undefined
prismaMock.desarrollo = undefined
prismaMock.$queryRawUnsafe.mockReset()
prismaMock.$queryRawUnsafe.mockResolvedValue([])
})
describe('normalizarReglas (JSON de la BD)', () => {
it('acepta el JSON con la forma de las constantes y conserva los valores', () => {
const reglas = normalizarReglas('DIAGONAL', JSON.parse(reglasJson(DESARROLLOS['DIAGONAL']!)))
expect(reglas).not.toBeNull()
expect(reglas?.id).toBe('DIAGONAL')
expect(reglas?.propuestas).toHaveLength(4)
expect(reglas?.propuestas[2]?.descuentoDefault).toBe(0.02)
})
it('marca la propuesta desactivada por el admin (activa/activo)', () => {
const bruto = JSON.parse(reglasJson(DESARROLLOS['SENDA']!)) as {
propuestas: Record<string, unknown>[]
}
brinda(bruto.propuestas, 'CONTADO').activa = false
const reglas = normalizarReglas('SENDA', bruto)
expect(reglas?.propuestas.find((p) => p.tipo === 'CONTADO')?.activa).toBe(false)
})
it('rechaza JSON sin la forma esperada (precioUnico, propuestas, claves)', () => {
expect(normalizarReglas('DIAGONAL', null)).toBeNull()
expect(normalizarReglas('DIAGONAL', 'DIAGONAL')).toBeNull()
expect(normalizarReglas('DIAGONAL', { precioUnico: true })).toBeNull()
expect(
normalizarReglas('DIAGONAL', {
precioUnico: true,
fuenteDeN: 'fijo',
propuestas: [{ tipo: 'CONTADO' }],
}),
).toBeNull()
})
})
describe('getReglasGuardadas / getReglasEfectivas', () => {
it('sin filas guardadas devuelve las constantes actuales (compatibilidad)', async () => {
usarModeloPrisma().mockResolvedValue([])
const efectivas = await getReglasEfectivas()
expect(Object.keys(efectivas).sort()).toEqual(Object.keys(DESARROLLOS).sort())
for (const [id, constantes] of Object.entries(DESARROLLOS)) {
expect(efectivas[id]).toEqual(constantes)
}
expect(await getReglasDesarrollo('SENDA')).toEqual(DESARROLLOS['SENDA'])
})
it('las reglas de la BD ganan a las constantes del desarrollo', async () => {
const conDescuento = {
...DESARROLLOS['DIAGONAL']!,
propuestas: DESARROLLOS['DIAGONAL']!.propuestas.map((p) =>
p.tipo === 'CONTADO' ? { ...p, descuentoDefault: 0.07 } : p,
),
}
usarModeloPrisma().mockResolvedValue([
{ desarrolloId: 'DIAGONAL', parametrosJson: reglasJson(conDescuento) },
])
const reglas = await getReglasDesarrollo('DIAGONAL')
const contado = reglas.propuestas.find((p) => p.tipo === 'CONTADO')
expect(contado?.descuentoDefault).toBe(0.07)
expect(DESARROLLOS['DIAGONAL']!.propuestas[2]?.descuentoDefault).toBe(0.02)
// Los demás desarrollos siguen en constantes
expect((await getReglasDesarrollo('SOLANA')).propuestas[1]?.pctEnganche).toBe(0)
})
it('usa la columna activoEnCotizador y también el campo dentro del JSON', async () => {
usarModeloPrisma().mockResolvedValue([
{
desarrolloId: 'SOLANA',
parametrosJson: reglasJson(DESARROLLOS['SOLANA']!),
activoEnCotizador: false,
},
{
desarrolloId: 'ESTANZA',
parametrosJson: reglasJson(DESARROLLOS['ESTANZA']!, { activoEnCotizador: false }),
},
])
expect((await getReglasDesarrollo('SOLANA')).activoEnCotizador).toBe(false)
expect((await getReglasDesarrollo('ESTANZA')).activoEnCotizador).toBe(false)
expect((await getReglasDesarrollo('DIAGONAL')).activoEnCotizador).toBeUndefined()
})
it('resuelve la clave del desarrollo por su id (las reglas apuntan al uuid) y lee su ficha', async () => {
usarModeloPrisma().mockResolvedValue([
{
desarrolloId: 'uuid-diagonal',
parametrosJson: reglasJson({ ...DESARROLLOS['DIAGONAL']!, nombre: undefined as never }),
},
])
usarCatalogo([
ficha('uuid-diagonal', 'DIAGONAL', { activoEnCotizador: false, nombre: 'Diagonal Residencial' }),
ficha('uuid-senda', 'SENDA'),
])
const reglas = await getReglasDesarrollo('DIAGONAL')
// La clave sale del catálogo (id → clave), no del uuid
expect(reglas.propuestas).toHaveLength(4)
// El nombre y la bandera salen de la ficha del desarrollo
expect(reglas.nombre).toBe('Diagonal Residencial')
expect(reglas.activoEnCotizador).toBe(false)
// Las reglas efectivas y el desarrollo activo siguen saliendo de constantes
const efectivas = await getReglasEfectivas()
expect(efectivas['SENDA']).toEqual(DESARROLLOS['SENDA'])
expect(efectivas['SOLANA']?.propuestas[1]?.pctEnganche).toBe(0)
})
it('ignora filas sin desarrollo identificable o sin parámetros', async () => {
const sinClave = JSON.parse(reglasJson(DESARROLLOS['SENDA']!)) as Record<string, unknown>
delete sinClave['id']
usarModeloPrisma().mockResolvedValue([
{ desarrolloId: '', parametrosJson: JSON.stringify(sinClave) },
{ desarrolloId: 'SENDA', parametrosJson: null },
null,
])
expect((await getReglasGuardadas()).size).toBe(0)
})
it('lee la tabla por SQL crudo cuando el cliente Prisma no tiene el modelo', async () => {
prismaMock.$queryRawUnsafe.mockImplementation(async (sql: string) => {
if (sql.includes('"ReglaCotizadorDesarrollo"')) {
return [
{ desarrolloId: 'uuid-senda', parametrosJson: reglasJson(DESARROLLOS['SENDA']!) },
{ desarrollo: 'SOLANA', parametros: reglasJson(DESARROLLOS['SOLANA']!) },
]
}
return [ficha('uuid-senda', 'SENDA'), ficha('uuid-solana', 'SOLANA')]
})
const reglas = await getReglasEfectivas()
expect(prismaMock.$queryRawUnsafe).toHaveBeenCalledWith(
expect.stringContaining('"ReglaCotizadorDesarrollo"'),
)
expect(reglas['SENDA']?.propuestas).toHaveLength(4)
expect(reglas['SOLANA']?.propuestas[1]?.pctEnganche).toBe(0)
})
})
describe('respaldo en constantes (nunca falla la cotización)', () => {
it('JSON corrupto: respaldo en las constantes con warning', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
usarModeloPrisma().mockResolvedValue([
{ desarrolloId: 'DIAGONAL', parametrosJson: '{"propuestas": [' },
])
const reglas = await getReglasDesarrollo('DIAGONAL')
expect(reglas).toEqual(DESARROLLOS['DIAGONAL'])
expect(warn).toHaveBeenCalledWith(expect.stringContaining('JSON inválido'))
warn.mockRestore()
})
it('JSON válido pero con forma inesperada: respaldo en las constantes con warning', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
usarModeloPrisma().mockResolvedValue([
{ desarrolloId: 'DIAGONAL', parametrosJson: JSON.stringify({ precioUnico: true }) },
])
const reglas = await getReglasDesarrollo('DIAGONAL')
expect(reglas).toEqual(DESARROLLOS['DIAGONAL'])
expect(warn).toHaveBeenCalledWith(expect.stringContaining('forma inesperada'))
warn.mockRestore()
})
it('tabla inexistente: respaldo en las constantes, sin lanzar y con un solo warning', async () => {
vi.resetModules()
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
prismaMock.$queryRawUnsafe.mockRejectedValue(new Error('no such table: ReglaCotizadorDesarrollo'))
const servicio = await import('./reglas-desarrollo.service.js')
const reglas = await servicio.getReglasDesarrollo('SENDA')
const efectivas = await servicio.getReglasEfectivas()
expect(reglas).toEqual(DESARROLLOS['SENDA'])
expect(Object.keys(efectivas)).toHaveLength(Object.keys(DESARROLLOS).length)
expect(warn).toHaveBeenCalledTimes(1)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('constantes'), expect.any(Error))
warn.mockRestore()
})
})

View File

@@ -0,0 +1,417 @@
/**
* Reglas comerciales del cotizador por desarrollo.
*
* Las reglas las configura el admin y viven en la tabla `ReglaCotizadorDesarrollo`
* (una fila por desarrollo, con el JSON de parámetros). Este servicio las lee y
* las valida; **nunca** puede tumbar una cotización: si el desarrollo no tiene
* reglas guardadas, la tabla no existe todavía o el JSON viene corrupto, se cae
* a las constantes de `cotizacion.service.ts` (los valores históricos) y se
* registra un warning.
*/
import { prisma } from './prisma.js'
import {
DESARROLLOS,
type BasePrecio,
type DescuentosPropuestas,
type ModoMensualidades,
type ParametrosDesarrollo,
type PropuestaConfig,
type TipoPropuesta,
} from './cotizacion.service.js'
/** Reglas efectivas indexadas por id de desarrollo ('DIAGONAL', 'SENDA', …) */
export type ReglasDesarrollo = Record<string, ParametrosDesarrollo>
const DESARROLLO_DEFAULT = 'DIAGONAL'
const TIPOS: ReadonlyArray<TipoPropuesta> = [
'CREDITO_BANCARIO',
'CREDITO_INFONAVIT_COFINAVIT',
'CONTADO',
'SEMI_CONTADO',
]
const CLAVES_DESCUENTO: ReadonlyArray<keyof DescuentosPropuestas> = [
'creditoBancario',
'creditoInfonavit',
'contado',
'semiContado',
]
const BASES: ReadonlyArray<BasePrecio> = ['lista', 'final']
const MODOS: ReadonlyArray<ModoMensualidades> = [
'ninguna',
'remanente',
'remanenteLista',
'porcentaje',
]
function esTexto(valor: unknown): valor is string {
return typeof valor === 'string' && valor.trim() !== ''
}
function esNumero(valor: unknown): valor is number {
return typeof valor === 'number' && Number.isFinite(valor)
}
function esBase(valor: unknown): valor is BasePrecio {
return typeof valor === 'string' && (BASES as ReadonlyArray<string>).includes(valor)
}
function esModo(valor: unknown): valor is ModoMensualidades {
return typeof valor === 'string' && (MODOS as ReadonlyArray<string>).includes(valor)
}
/** Normaliza una propuesta del JSON de la BD; null si está corrupta */
function normalizarPropuesta(bruto: unknown): PropuestaConfig | null {
if (typeof bruto !== 'object' || bruto === null) return null
const p = bruto as Record<string, unknown>
const tipo = p['tipo']
const clave = p['claveDescuento']
if (typeof tipo !== 'string' || !(TIPOS as ReadonlyArray<string>).includes(tipo)) return null
if (typeof clave !== 'string' || !(CLAVES_DESCUENTO as ReadonlyArray<string>).includes(clave))
return null
if (!esTexto(p['titulo'])) return null
if (!esNumero(p['descuentoDefault']) || !esNumero(p['pctEnganche']) || !esNumero(p['pctPagoFinal']))
return null
if (!esBase(p['engancheBase']) || !esBase(p['pagoFinalBase'])) return null
if (!esModo(p['modoMensualidades'])) return null
const propuesta: PropuestaConfig = {
tipo: tipo as TipoPropuesta,
claveDescuento: clave as keyof DescuentosPropuestas,
titulo: p['titulo'].trim(),
descuentoDefault: p['descuentoDefault'],
pctEnganche: p['pctEnganche'],
engancheBase: p['engancheBase'],
pctPagoFinal: p['pctPagoFinal'],
pagoFinalBase: p['pagoFinalBase'],
modoMensualidades: p['modoMensualidades'],
}
if (p['engancheCeroSinApartado'] === true) propuesta.engancheCeroSinApartado = true
const activa = p['activa'] ?? p['activo']
if (typeof activa === 'boolean') propuesta.activa = activa
return propuesta
}
/**
* Valida y normaliza el JSON de reglas de un desarrollo. Devuelve null (para
* que el llamador caiga a las constantes) si el JSON no tiene la forma esperada.
*/
export function normalizarReglas(
desarrolloId: string,
bruto: unknown,
): ParametrosDesarrollo | null {
if (typeof bruto !== 'object' || bruto === null || Array.isArray(bruto)) return null
const r = bruto as Record<string, unknown>
if (typeof r['precioUnico'] !== 'boolean') return null
const fuente = r['fuenteDeN']
if (fuente !== 'fechaEntrega' && fuente !== 'fijo' && fuente !== 'porUnidad') return null
const propuestasBrutas = r['propuestas']
if (!Array.isArray(propuestasBrutas) || propuestasBrutas.length === 0) return null
const propuestas: PropuestaConfig[] = []
for (const propuestaBruta of propuestasBrutas) {
const propuesta = normalizarPropuesta(propuestaBruta)
if (!propuesta) return null // una propuesta corrupta invalida el JSON completo
propuestas.push(propuesta)
}
const id = desarrolloId.trim().toUpperCase()
const params: ParametrosDesarrollo = {
id,
precioUnico: r['precioUnico'],
fuenteDeN: fuente,
propuestas,
}
if (esTexto(r['nombre'])) params.nombre = r['nombre'].trim()
if (esNumero(r['nFijo'])) params.nFijo = r['nFijo']
if (esNumero(r['nDefault'])) params.nDefault = r['nDefault']
if (r['cuotaMantenimiento'] === null) params.cuotaMantenimiento = null
else if (esNumero(r['cuotaMantenimiento'])) params.cuotaMantenimiento = r['cuotaMantenimiento']
if (esTexto(r['prefijoUnidadPdf'])) params.prefijoUnidadPdf = r['prefijoUnidadPdf'].trim()
const activo = r['activoEnCotizador'] ?? r['activo']
if (typeof activo === 'boolean') params.activoEnCotizador = activo
return params
}
/** Fila de reglas ya mapeada a nombres de campo conocidos */
interface FilaReglas {
desarrolloId: string
parametros: unknown
activoEnCotizador: boolean | undefined
}
/** Ficha mínima del desarrollo (tabla `Desarrollo`): la clave y sus banderas */
interface FichaDesarrollo {
clave: string
activoEnCotizador: boolean | undefined
nombre: string | undefined
prefijoUnidadPdf: string | undefined
}
interface CatalogoDesarrollos {
porClave: Map<string, FichaDesarrollo>
/** Desarrollo.id → clave ('DIAGONAL', 'SENDA', …) */
clavePorId: Map<string, string>
}
/** Primer valor presente de `claves` en la fila que cumpla el filtro */
function primerValor(
fila: Record<string, unknown>,
claves: ReadonlyArray<string>,
acepta: (valor: unknown) => boolean,
): unknown {
for (const clave of claves) {
const valor = fila[clave]
if (valor !== undefined && valor !== null && acepta(valor)) return valor
}
return undefined
}
/** Booleanos de SQLite crudo: 1/0 además de true/false */
function esBandera(valor: unknown): valor is boolean | 0 | 1 {
return typeof valor === 'boolean' || valor === 0 || valor === 1
}
function aBandera(valor: unknown): boolean | undefined {
if (valor === true || valor === 1) return true
if (valor === false || valor === 0) return false
return undefined
}
/**
* Mapea una fila cruda (Prisma o SQL) a `FilaReglas`. Los nombres de columna se
* resuelven por heurística para tolerar cambios del modelo mientras otro agente
* construye el módulo de administración: `desarrolloId`/`desarrollo` y
* `parametrosJson`/`parametros`/`reglas`.
*/
function mapearFila(fila: unknown): FilaReglas | null {
if (typeof fila !== 'object' || fila === null) return null
const f = fila as Record<string, unknown>
const desarrollo = primerValor(
f,
['desarrolloId', 'desarrollo', 'desarrollo_id', 'proyecto', 'id'],
esTexto,
)
const parametros = primerValor(
f,
['parametrosJson', 'parametros_json', 'parametros', 'reglas'],
(v) => typeof v === 'string' || typeof v === 'object',
)
if (typeof desarrollo !== 'string') return null
const activo = primerValor(f, ['activoEnCotizador', 'activo'], esBandera)
return {
desarrolloId: desarrollo,
parametros,
activoEnCotizador: aBandera(activo),
}
}
/** Cliente Prisma con el modelo de reglas, si ya está generado */
function modeloReglas(): { findMany: (args?: unknown) => Promise<unknown[]> } | null {
const cliente = prisma as unknown as Record<string, unknown>
for (const nombre of ['reglaCotizadorDesarrollo', 'reglaCotizador', 'reglasCotizador']) {
const modelo = cliente[nombre] as { findMany?: unknown } | undefined
if (modelo && typeof modelo.findMany === 'function') {
return modelo as { findMany: (args?: unknown) => Promise<unknown[]> }
}
}
return null
}
/**
* Filas de reglas guardadas. Primero intenta el modelo de Prisma; si el cliente
* todavía no lo tiene generado, lee la tabla por SQL crudo. Cualquier fallo se
* traduce en "no hay reglas guardadas" (nunca en un error de cotización).
*/
async function leerFilasReglas(): Promise<FilaReglas[]> {
const modelo = modeloReglas()
if (modelo) {
const filas = await modelo.findMany()
return filas.map(mapearFila).filter((f): f is FilaReglas => f !== null)
}
const crudo = (prisma as unknown as { $queryRawUnsafe?: unknown }).$queryRawUnsafe
if (typeof crudo !== 'function') return []
const filas = await prisma.$queryRawUnsafe<unknown[]>(
'SELECT * FROM "ReglaCotizadorDesarrollo"',
)
return (Array.isArray(filas) ? filas : []).map(mapearFila).filter((f): f is FilaReglas => f !== null)
}
let avisoCatalogoEmitido = false
let avisoLecturaEmitido = false
/**
* Catálogo de desarrollos (tabla `Desarrollo`): de ahí sale la clave real de
* cada fila de reglas (las reglas apuntan al id del desarrollo) y la bandera
* `activoEnCotizador`. Tolerante: si la tabla no existe todavía, el catálogo
* queda vacío y las reglas se resuelven por la clave que traiga el JSON.
*/
async function leerCatalogo(): Promise<CatalogoDesarrollos> {
const porClave = new Map<string, FichaDesarrollo>()
const clavePorId = new Map<string, string>()
let leidas: unknown[]
try {
const modelo = (prisma as unknown as Record<string, unknown>)['desarrollo'] as
| { findMany?: unknown }
| undefined
if (modelo && typeof modelo.findMany === 'function') {
leidas = await (modelo.findMany as (args?: unknown) => Promise<unknown[]>)({
select: { id: true, clave: true, nombre: true, activoEnCotizador: true, prefijoUnidadPdf: true },
})
} else if (typeof prisma.$queryRawUnsafe === 'function') {
leidas = await prisma.$queryRawUnsafe<unknown[]>('SELECT * FROM "Desarrollo"')
} else {
return { porClave, clavePorId }
}
} catch (err) {
if (!avisoCatalogoEmitido) {
avisoCatalogoEmitido = true
// eslint-disable-next-line no-console
console.warn(
'[reglas-cotizador] No se pudo leer el catálogo de desarrollos; se usan las constantes.',
err,
)
}
return { porClave, clavePorId }
}
for (const fila of Array.isArray(leidas) ? leidas : []) {
if (typeof fila !== 'object' || fila === null) continue
const f = fila as Record<string, unknown>
const clave = primerValor(f, ['clave', 'desarrolloId', 'desarrollo'], esTexto)
if (typeof clave !== 'string') continue
const nombre = primerValor(f, ['nombre'], esTexto)
const prefijo = primerValor(f, ['prefijoUnidadPdf'], esTexto)
const ficha: FichaDesarrollo = {
clave: clave.trim().toUpperCase(),
activoEnCotizador: aBandera(primerValor(f, ['activoEnCotizador', 'activo'], esBandera)),
nombre: typeof nombre === 'string' ? nombre : undefined,
prefijoUnidadPdf: typeof prefijo === 'string' ? prefijo : undefined,
}
porClave.set(ficha.clave, ficha)
const id = primerValor(f, ['id'], esTexto)
if (typeof id === 'string') clavePorId.set(id, ficha.clave)
}
return { porClave, clavePorId }
}
/**
* Clave del desarrollo de una fila de reglas: primero el id guardado (uuid) que
* se resuelve con el catálogo, luego el `id` del propio JSON y, si no, la clave
* que venga en la columna.
*/
function claveDeFila(
fila: FilaReglas,
bruto: unknown,
catalogo: CatalogoDesarrollos,
): string | null {
const crudo = fila.desarrolloId.trim()
const delCatalogo = catalogo.clavePorId.get(crudo)
if (delCatalogo !== undefined) return delCatalogo
if (typeof bruto === 'object' && bruto !== null) {
const idJson = (bruto as Record<string, unknown>)['id']
if (esTexto(idJson)) return idJson.trim().toUpperCase()
}
return crudo === '' ? null : crudo.toUpperCase()
}
/**
* Reglas configuradas por el admin, indexadas por clave de desarrollo. Se
* descartan (con warning) las filas con JSON corrupto: esos desarrollos usan sus
* constantes.
*/
export async function getReglasGuardadas(): Promise<Map<string, ParametrosDesarrollo>> {
const guardadas = new Map<string, ParametrosDesarrollo>()
let filas: FilaReglas[]
try {
filas = await leerFilasReglas()
} catch (err) {
if (!avisoLecturaEmitido) {
avisoLecturaEmitido = true
// eslint-disable-next-line no-console
console.warn(
'[reglas-cotizador] No se pudieron leer las reglas guardadas (¿tabla ReglaCotizadorDesarrollo sin crear?); se usan las constantes.',
err,
)
}
return guardadas
}
if (filas.length === 0) return guardadas
const catalogo = await leerCatalogo()
for (const fila of filas) {
if (fila.parametros === undefined) continue
let bruto = fila.parametros
if (typeof bruto === 'string') {
try {
bruto = JSON.parse(bruto)
} catch {
// eslint-disable-next-line no-console
console.warn(
`[reglas-cotizador] JSON inválido para el desarrollo ${fila.desarrolloId}; se usan las constantes.`,
)
continue
}
}
const clave = claveDeFila(fila, bruto, catalogo)
if (clave === null) {
// eslint-disable-next-line no-console
console.warn('[reglas-cotizador] Fila de reglas sin desarrollo; se ignora.')
continue
}
const reglas = normalizarReglas(clave, bruto)
if (!reglas) {
// eslint-disable-next-line no-console
console.warn(
`[reglas-cotizador] Reglas con forma inesperada para el desarrollo ${clave}; se usan las constantes.`,
)
continue
}
// La ficha del desarrollo completa lo que el JSON no traiga
const ficha = catalogo.porClave.get(clave)
if (reglas.nombre === undefined && ficha?.nombre !== undefined) reglas.nombre = ficha.nombre
if (reglas.prefijoUnidadPdf === undefined && ficha?.prefijoUnidadPdf !== undefined) {
reglas.prefijoUnidadPdf = ficha.prefijoUnidadPdf
}
// El desarrollo está activo solo si TODAS las fuentes que hablan de él lo
// dejan activo (la bandera vive en la ficha del desarrollo)
const banderas = [fila.activoEnCotizador, reglas.activoEnCotizador, ficha?.activoEnCotizador].filter(
(valor): valor is boolean => typeof valor === 'boolean',
)
if (banderas.length > 0) reglas.activoEnCotizador = banderas.every((valor) => valor)
guardadas.set(reglas.id, reglas)
}
return guardadas
}
/**
* Reglas efectivas de TODOS los desarrollos: lo guardado por el admin gana; los
* desarrollos sin reglas (o con JSON corrupto) caen a las constantes actuales.
*/
export async function getReglasEfectivas(): Promise<ReglasDesarrollo> {
const guardadas = await getReglasGuardadas()
const efectivas: ReglasDesarrollo = {}
for (const [id, constantes] of Object.entries(DESARROLLOS)) {
efectivas[id] = guardadas.get(id) ?? constantes
}
for (const [id, reglas] of guardadas) {
if (efectivas[id] === undefined) efectivas[id] = reglas
}
return efectivas
}
/** Reglas efectivas de un desarrollo; cae a las constantes (DIAGONAL si es desconocido) */
export async function getReglasDesarrollo(
desarrolloId: string | null | undefined,
): Promise<ParametrosDesarrollo> {
const id = (desarrolloId ?? DESARROLLO_DEFAULT).trim().toUpperCase() || DESARROLLO_DEFAULT
const guardadas = await getReglasGuardadas()
return guardadas.get(id) ?? DESARROLLOS[id] ?? DESARROLLOS[DESARROLLO_DEFAULT]!
}

View File

@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react'
import type { Unidad, TipoPropuesta } from '../types'
import { compararUnidades, type ColumnaComparador } from '../lib/comparadorUnidades'
import { formatoMoneda } from '../lib/formatters'
import { LISTA_DESARROLLOS, obtenerDesarrollo } from '../data/desarrollos'
import { SIN_REGLAS, desarrolloEfectivo, idsCotizables, type ReglasDesarrollo } from '../lib/reglas'
interface Props {
unidades: Unidad[]
@@ -10,6 +10,8 @@ interface Props {
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²`)
@@ -23,17 +25,25 @@ interface Fila {
/**
* 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. Solo ofrece unidades DISPONIBLES: no se compara (ni se cotiza)
* inventario apartado, vendido o bloqueado.
* 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 }: Props) {
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'),
[unidades],
() =>
unidades.filter(
(u) =>
(u.status ?? '').trim().toUpperCase() === 'DISPONIBLE' &&
desarrollos.includes(u.desarrollo ?? 'DIAGONAL'),
),
[unidades, desarrollos],
)
const porCodigo = useMemo(() => {
@@ -46,8 +56,11 @@ export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props
const unidadB = porCodigo.get(codigoB) ?? null
const comparacion = useMemo(
() => (unidadA && unidadB ? compararUnidades(unidadA, unidadB, tipo, apartado, acabados) : null),
[unidadA, unidadB, tipo, apartado, acabados],
() =>
unidadA && unidadB
? compararUnidades(unidadA, unidadB, tipo, apartado, acabados, reglas)
: null,
[unidadA, unidadB, tipo, apartado, acabados, reglas],
)
const filas: Fila[] = [
@@ -87,11 +100,11 @@ export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props
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>
{LISTA_DESARROLLOS.map((dev) => {
{desarrollos.map((dev) => {
const delDesarrollo = disponibles.filter((u) => (u.desarrollo ?? 'DIAGONAL') === dev)
if (delDesarrollo.length === 0) return null
return (
<optgroup key={dev} label={obtenerDesarrollo(dev).nombre}>
<optgroup key={dev} label={desarrolloEfectivo(reglas, dev).nombre}>
{delDesarrollo.map((u) => (
<option key={`${dev}|${u.codigo}`} value={`${dev}|${u.codigo}`}>
{u.codigo} — {u.modelo}

View File

@@ -1,10 +1,16 @@
import type { DescuentosPropuestas, ParametrosDesarrollo } from '../types'
/**
* Parámetros comerciales por desarrollo, replicados de los cotizadores Excel
* de producción (ver reference/cotizadores/*.md). NO "corregir" fórmulas:
* algunas usan precio de lista donde intuitivamente iría precio final; así
* está en los Excel y así debe quedar.
* Parámetros comerciales por desarrollo: VALORES POR DEFECTO / RESPALDO,
* replicados de los cotizadores Excel de producción (ver
* reference/cotizadores/*.md).
*
* Las reglas que usa el cotizador son las que el admin configura en la BD y
* llegan por el API (GET /desarrollos/reglas-cotizador); estas constantes son
* el respaldo para los desarrollos sin reglas guardadas y completan los campos
* que falten (ver lib/reglas.ts). NO "corregir" fórmulas: algunas usan precio
* de lista donde intuitivamente iría precio final; así está en los Excel y así
* debe quedar.
*/
const DIAGONAL: ParametrosDesarrollo = {
@@ -309,9 +315,13 @@ export const DESARROLLO_DEFAULT = 'DIAGONAL'
/** Ids en orden de presentación en el selector */
export const LISTA_DESARROLLOS: string[] = ['DIAGONAL', 'ATELIER', 'ESTANZA', 'SENDA', 'SOLANA']
/** Parámetros de un desarrollo; cae a DIAGONAL si el id es desconocido */
/**
* Parámetros de un desarrollo por CONSTANTE (respaldo); cae a DIAGONAL si el id
* es desconocido. Para las reglas efectivas (las del admin) usar
* `desarrolloEfectivo` de lib/reglas.ts.
*/
export function obtenerDesarrollo(id?: string | null): ParametrosDesarrollo {
return (id && DESARROLLOS[id]) || DESARROLLOS[DESARROLLO_DEFAULT]
return (id && DESARROLLOS[id]) || DESARROLLOS[DESARROLLO_DEFAULT]!
}
/** Descuentos por defecto del desarrollo, indexados por clave de propuesta */

View File

@@ -0,0 +1,108 @@
// @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 { 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.
*/
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 },
})
act(() => {
hook.result.current.setUnidad(unidadDiagonal)
})
return hook
}
afterEach(cleanup)
describe('useCotizacion y las reglas del admin', () => {
it('sin reglas ofrece las 4 propuestas de siempre', () => {
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()
})
it('una propuesta desactivada no se ofrece ni se puede elegir', () => {
const { result } = montar(reglasDiagonales((tipo) => tipo !== 'CONTADO'))
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)
})
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')
})
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('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,
),
},
}),
)
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)
})
})

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { Unidad, DescuentosPropuestas, ParametrosDesarrollo } from '../types'
import {
APARTADO_DEFAULT,
@@ -8,7 +8,14 @@ import {
validarEnganchePct,
type PropuestaResultado,
} from '../lib/calculations'
import { DESARROLLO_DEFAULT, descuentosDefault, obtenerDesarrollo } from '../data/desarrollos'
import { DESARROLLO_DEFAULT } from '../data/desarrollos'
import {
SIN_REGLAS,
descuentosDefaultEfectivo,
desarrolloEfectivo,
propuestasVigentes,
type ReglasDesarrollo,
} from '../lib/reglas'
interface UseCotizacionResult {
desarrollo: string
@@ -40,6 +47,8 @@ interface UseCotizacionResult {
/** Número efectivo de mensualidades (la elegida o la calculada) */
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") */
@@ -59,24 +68,47 @@ export interface EstadoRestaurar {
numMensualidades?: number | null
}
/** Estado central de la cotización: desarrollo, unidad, acabados, apartado y descuentos */
export function useCotizacion(): UseCotizacionResult {
/**
* 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.
*/
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>(descuentosDefault(DESARROLLO_DEFAULT))
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 params = obtenerDesarrollo(desarrollo)
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(descuentosDefault(id))
setDescuentos(descuentosDefaultEfectivo(reglas, id))
setPropuestaIndex(0)
setNumMensualidades(null)
}
@@ -90,10 +122,12 @@ export function useCotizacion(): UseCotizacionResult {
setUnidad(estado.unidad)
setAcabados(estado.acabados)
setApartado(estado.apartado)
const base = descuentosDefault(estado.desarrollo)
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 = obtenerDesarrollo(estado.desarrollo).propuestas[idx]?.claveDescuento
const clave = vigentes[idx]?.claveDescuento
if (clave) base[clave] = estado.descuento
}
setDescuentos(base)
@@ -106,9 +140,10 @@ export function useCotizacion(): UseCotizacionResult {
setAcabados(false)
setApartado(APARTADO_DEFAULT)
setEnganchePct(null)
setDescuentos(descuentosDefault(desarrollo))
setDescuentos(descuentosDefaultEfectivo(reglas, desarrollo))
setPropuestaIndex(0)
setNumMensualidades(null)
setAvisoPropuestaDesactivada(null)
}
const numMesesCalculado = useMemo(
@@ -134,8 +169,40 @@ export function useCotizacion(): UseCotizacionResult {
const propuestas = useMemo(() => {
if (!unidad) return []
return calcularPropuestas({ unidad, acabados, apartado: apartadoEfectivo, descuentos, desarrollo, enganchePct: engancheEfectivo, numMeses: numMeses ?? undefined })
}, [unidad, acabados, apartadoEfectivo, descuentos, desarrollo, engancheEfectivo, numMeses])
return calcularPropuestas({
unidad,
acabados,
apartado: apartadoEfectivo,
descuentos,
desarrollo,
reglas: params,
enganchePct: engancheEfectivo,
numMeses: numMeses ?? undefined,
})
}, [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])
return {
desarrollo,
@@ -160,6 +227,7 @@ export function useCotizacion(): UseCotizacionResult {
setNumMensualidades,
numMeses,
propuestas,
avisoPropuestaDesactivada,
restaurar,
nueva,
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useState } from 'react'
import type { Unidad } from '../types'
import { getUnidades } from '../services/api'
import { getReglasCotizador, getUnidades } from '../services/api'
import { SIN_REGLAS, type ReglasDesarrollo } from '../lib/reglas'
interface UseUnidadesResult {
unidades: Unidad[]
@@ -8,21 +9,29 @@ interface UseUnidadesResult {
error: string | null
/** true cuando el inventario viene del seed local (sin conexión al API) */
usandoSeed: boolean
/** Reglas comerciales por desarrollo (vacío => se usan las constantes) */
reglas: ReglasDesarrollo
}
/** Carga el inventario de unidades desde el API Gateway, con fallback al seed local */
/**
* Carga el inventario de unidades desde el API Gateway, con fallback al seed
* local, junto con las reglas comerciales configuradas por el admin (que caen a
* los valores por defecto si no llegan).
*/
export function useUnidades(): UseUnidadesResult {
const [unidades, setUnidades] = useState<Unidad[]>([])
const [reglas, setReglas] = useState<ReglasDesarrollo>(SIN_REGLAS)
const [cargando, setCargando] = useState(true)
const [error, setError] = useState<string | null>(null)
const [usandoSeed, setUsandoSeed] = useState(false)
useEffect(() => {
let activo = true
getUnidades()
.then(({ unidades, usandoSeed }) => {
Promise.all([getUnidades(), getReglasCotizador()])
.then(([{ unidades, usandoSeed }, reglasApi]) => {
if (activo) {
setUnidades(unidades)
setReglas(reglasApi)
setUsandoSeed(usandoSeed)
setCargando(false)
}
@@ -38,5 +47,5 @@ export function useUnidades(): UseUnidadesResult {
}
}, [])
return { unidades, cargando, error, usandoSeed }
return { unidades, cargando, error, usandoSeed, reglas }
}

View File

@@ -6,6 +6,7 @@ import type {
PropuestaConfig,
} from '../types'
import { DESARROLLO_DEFAULT, obtenerDesarrollo } from '../data/desarrollos'
import { propuestasVigentes } from './reglas'
/**
* Cálculos replicados fielmente de los Excel de producción "COTIZADOR" de
@@ -83,6 +84,11 @@ export interface CotizacionInput {
descuentos: DescuentosPropuestas
/** Desarrollo cuyos parámetros aplican; por defecto unidad.desarrollo ?? 'DIAGONAL' */
desarrollo?: string
/**
* Reglas comerciales efectivas del desarrollo (las del admin, con respaldo en
* las constantes). Si no vienen, se usan las constantes.
*/
reglas?: ParametrosDesarrollo
/** Permite forzar N en tests; por defecto se resuelve según la fuenteDeN del desarrollo */
numMeses?: number
/**
@@ -273,13 +279,13 @@ export function indiceMenorTotal(totales: number[]): number {
return indice
}
/** Calcula las propuestas comerciales del desarrollo de la unidad */
/** Propuestas comerciales vigentes del desarrollo de la unidad */
export function calcularPropuestas(input: CotizacionInput): PropuestaResultado[] {
const { unidad, acabados, apartado, descuentos } = input
const params = obtenerDesarrollo(input.desarrollo ?? unidad.desarrollo ?? DESARROLLO_DEFAULT)
const params = input.reglas ?? obtenerDesarrollo(input.desarrollo ?? unidad.desarrollo ?? DESARROLLO_DEFAULT)
const precioLista = obtenerPrecioLista(unidad, acabados)
const n = input.numMeses ?? resolverNumMensualidades(unidad, params)
return params.propuestas.map((cfg) =>
return propuestasVigentes(params).map((cfg) =>
calcularPropuestaConfig(cfg, precioLista, apartado, descuentos[cfg.claveDescuento], n, input.enganchePct ?? null),
)
}

View File

@@ -5,7 +5,12 @@ import {
totalPagado,
type PropuestaResultado,
} from './calculations'
import { descuentosDefault, obtenerDesarrollo } from '../data/desarrollos'
import {
SIN_REGLAS,
descuentosDefaultEfectivo,
desarrolloEfectivo,
type ReglasDesarrollo,
} from './reglas'
export interface ColumnaComparador {
unidad: Unidad
@@ -36,16 +41,18 @@ export function compararUnidades(
tipo: TipoPropuesta,
apartado: number,
acabados: boolean,
reglas: ReglasDesarrollo = SIN_REGLAS,
): ComparacionUnidades {
const columna = (unidad: Unidad): ColumnaComparador => {
const desarrolloId = unidad.desarrollo ?? 'DIAGONAL'
const params = obtenerDesarrollo(desarrolloId)
const params = desarrolloEfectivo(reglas, desarrolloId)
const propuestas = calcularPropuestas({
unidad,
acabados,
apartado,
descuentos: descuentosDefault(desarrolloId),
descuentos: descuentosDefaultEfectivo(reglas, desarrolloId),
desarrollo: desarrolloId,
reglas: params,
})
const propuesta = propuestas.find((p) => p.tipo === tipo) ?? propuestas[0]
if (!propuesta) throw new Error(`El desarrollo ${desarrolloId} no tiene propuestas`)

View File

@@ -0,0 +1,164 @@
import { describe, it, expect } from 'vitest'
import { APARTADO_DEFAULT, calcularPropuestas, totalPagado } from './calculations'
import {
SIN_REGLAS,
descuentosDefaultEfectivo,
desarrolloEfectivo,
fusionarReglas,
idsCotizables,
propuestasVigentes,
reglasDesdeApi,
} from './reglas'
import { DESARROLLOS, LISTA_DESARROLLOS, descuentosDefault } from '../data/desarrollos'
import { INVENTARIO_SEED } from '../data/unidades'
import type { DescuentosPropuestas, ParametrosDesarrollo, Unidad } from '../types'
/**
* Reglas comerciales configurables: el cotizador usa las que manda el API (con
* respaldo en las constantes). Las unidades son las del inventario real.
*/
const DESCUENTOS: DescuentosPropuestas = {
creditoBancario: 0.01,
creditoInfonavit: 0.005,
contado: 0.03,
semiContado: 0.015,
}
/** Tres unidades reales del inventario por desarrollo */
function unidadesDe(desarrollo: string): Unidad[] {
return INVENTARIO_SEED.filter((u) => u.desarrollo === desarrollo).slice(0, 3)
}
/** Reglas tal como las devuelve el API: JSON serializado con campos extra del admin */
function reglasDeApi(extras: Record<string, Record<string, unknown>> = {}): Record<string, unknown> {
const mapa: Record<string, unknown> = {}
for (const [id, params] of Object.entries(DESARROLLOS)) {
mapa[id] = JSON.parse(
JSON.stringify({
...params,
activoEnCotizador: true,
propuestas: params.propuestas.map((p) => ({ ...p, activa: true })),
...(extras[id] ?? {}),
}),
) as unknown
}
return mapa
}
describe('reglasDesdeApi / desarrolloEfectivo', () => {
it('sin reglas del API todo se calcula con las constantes', () => {
expect(idsCotizables(SIN_REGLAS)).toEqual(LISTA_DESARROLLOS)
for (const id of LISTA_DESARROLLOS) {
expect(desarrolloEfectivo(SIN_REGLAS, id)).toEqual(DESARROLLOS[id])
expect(descuentosDefaultEfectivo(SIN_REGLAS, id)).toEqual(descuentosDefault(id))
}
})
it('las reglas del API ganan a la constante y completan los campos que falten', () => {
const reglas = reglasDesdeApi({
DIAGONAL: { propuestas: [{ tipo: 'CONTADO', descuentoDefault: 0.08 }] },
})
const params = desarrolloEfectivo(reglas, 'DIAGONAL')
// El campo que vino del API manda...
expect(params.propuestas).toHaveLength(1)
expect(params.propuestas[0]?.descuentoDefault).toBe(0.08)
// ...y lo que falta se completa con la constante del desarrollo
expect(params.propuestas[0]?.titulo).toBe('Contado')
expect(params.propuestas[0]?.pctEnganche).toBe(0.9)
expect(params.nombre).toBe('Diagonal')
expect(params.nDefault).toBe(13)
expect(params.cuotaMantenimiento).toBe(1500)
expect(descuentosDefaultEfectivo(reglas, 'DIAGONAL').contado).toBe(0.08)
})
it('un JSON que no se puede usar deja al desarrollo con su constante', () => {
const reglas = reglasDesdeApi({
SENDA: null,
ATELIER: 'roto',
ESTANZA: { propuestas: [] },
SOLANA: { propuestas: [{ tipo: 'NO_EXISTE' }] },
})
expect(reglas).toEqual({})
expect(desarrolloEfectivo(reglas, 'SENDA')).toEqual(DESARROLLOS['SENDA'])
})
it('un desarrollo con activoEnCotizador false no se ofrece (el API también lo rechaza)', () => {
const reglas = reglasDesdeApi(reglasDeApi({ SOLANA: { activoEnCotizador: false } }))
expect(idsCotizables(reglas)).toEqual(['DIAGONAL', 'ATELIER', 'ESTANZA', 'SENDA'])
expect(desarrolloEfectivo(reglas, 'SOLANA').activoEnCotizador).toBe(false)
})
it('una propuesta desactivada no se ofrece ni se calcula; el resto sigue vigente', () => {
const base = DESARROLLOS['DIAGONAL']!
const reglas = reglasDesdeApi(
reglasDeApi({
DIAGONAL: {
propuestas: base.propuestas.map((p) => ({ ...p, activa: p.tipo !== 'CONTADO' })),
},
}),
)
const params = desarrolloEfectivo(reglas, 'DIAGONAL')
const unidad = unidadesDe('DIAGONAL')[0]!
expect(propuestasVigentes(params).map((p) => p.tipo)).toEqual([
'CREDITO_BANCARIO',
'CREDITO_INFONAVIT_COFINAVIT',
'SEMI_CONTADO',
])
const propuestas = calcularPropuestas({
unidad,
acabados: false,
apartado: APARTADO_DEFAULT,
descuentos: descuentosDefaultEfectivo(reglas, 'DIAGONAL'),
reglas: params,
})
expect(propuestas.map((p) => p.tipo)).toEqual([
'CREDITO_BANCARIO',
'CREDITO_INFONAVIT_COFINAVIT',
'SEMI_CONTADO',
])
})
})
describe('reglas equivalentes de BD vs constantes: mismos números', () => {
it('los 5 desarrollos cotizan igual con las reglas del API que con las constantes', () => {
const reglas = reglasDesdeApi(reglasDeApi())
for (const id of LISTA_DESARROLLOS) {
const unidades = unidadesDe(id)
expect(unidades).toHaveLength(3)
for (const unidad of unidades) {
for (const acabados of [false, true]) {
const conConstantes = calcularPropuestas({
unidad,
acabados,
apartado: APARTADO_DEFAULT,
descuentos: DESCUENTOS,
desarrollo: id,
})
const conReglas = calcularPropuestas({
unidad,
acabados,
apartado: APARTADO_DEFAULT,
descuentos: DESCUENTOS,
desarrollo: id,
reglas: desarrolloEfectivo(reglas, id),
})
expect(conReglas).toEqual(conConstantes)
for (const p of conReglas) {
expect(totalPagado(p, APARTADO_DEFAULT)).not.toBeNull()
}
}
}
}
})
it('fusionarReglas no cambia un solo número cuando la BD tiene los valores de hoy', () => {
const fusionadas = fusionarReglas('SENDA', reglasDeApi()['SENDA']) as ParametrosDesarrollo
expect(fusionadas).toEqual(DESARROLLOS['SENDA'])
})
})

View File

@@ -0,0 +1,227 @@
import type {
BasePrecio,
DescuentosPropuestas,
ModoMensualidades,
ParametrosDesarrollo,
PropuestaConfig,
TipoPropuesta,
} from '../types'
import { DESARROLLOS, LISTA_DESARROLLOS, obtenerDesarrollo } from '../data/desarrollos'
/**
* Reglas comerciales del cotizador.
*
* Las reglas "efectivas" las configura el admin y llegan del API
* (`GET /desarrollos/reglas-cotizador`); `data/desarrollos.ts` son los valores
* por defecto/respaldo: si un desarrollo no trae reglas (o llegan a medias), se
* completan campo por campo con su constante. Sin reglas del API, la app se
* comporta exactamente como antes.
*
* Igual que en el motor, las fórmulas no se tocan: aquí solo se decide de dónde
* salen los parámetros.
*/
/** Reglas efectivas indexadas por id de desarrollo ('DIAGONAL', 'SENDA', …) */
export type ReglasDesarrollo = Record<string, ParametrosDesarrollo>
/** Sin reglas del API: todo se calcula con las constantes */
export const SIN_REGLAS: ReglasDesarrollo = {}
const TIPOS: TipoPropuesta[] = [
'CREDITO_BANCARIO',
'CREDITO_INFONAVIT_COFINAVIT',
'CONTADO',
'SEMI_CONTADO',
]
const CLAVES: (keyof DescuentosPropuestas)[] = [
'creditoBancario',
'creditoInfonavit',
'contado',
'semiContado',
]
/** Clave de descuento que le toca a cada tipo cuando ni la BD ni la constante la traen */
const CLAVE_POR_TIPO: Record<TipoPropuesta, keyof DescuentosPropuestas> = {
CREDITO_BANCARIO: 'creditoBancario',
CREDITO_INFONAVIT_COFINAVIT: 'creditoInfonavit',
CONTADO: 'contado',
SEMI_CONTADO: 'semiContado',
}
function texto(valor: unknown): string | null {
return typeof valor === 'string' && valor.trim() !== '' ? valor.trim() : null
}
function numero(valor: unknown): number | null {
return typeof valor === 'number' && Number.isFinite(valor) ? valor : null
}
function tipoPropuesta(valor: unknown): TipoPropuesta | null {
return typeof valor === 'string' && (TIPOS as string[]).includes(valor)
? (valor as TipoPropuesta)
: null
}
function claveDescuento(valor: unknown): keyof DescuentosPropuestas | null {
return typeof valor === 'string' && (CLAVES as string[]).includes(valor)
? (valor as keyof DescuentosPropuestas)
: null
}
function basePrecio(valor: unknown): BasePrecio | null {
return valor === 'lista' || valor === 'final' ? valor : null
}
function modoMensualidades(valor: unknown): ModoMensualidades | null {
return valor === 'ninguna' || valor === 'remanente' || valor === 'remanenteLista' || valor === 'porcentaje'
? valor
: null
}
/** Propuestas vigentes: el admin puede desactivar una propuesta por desarrollo */
export function propuestasVigentes(params: ParametrosDesarrollo): PropuestaConfig[] {
return params.propuestas.filter(propuestaVigente)
}
/** ¿La propuesta se ofrece en el cotizador? (activa !== false) */
export function propuestaVigente(propuesta: PropuestaConfig): boolean {
return propuesta.activa !== false
}
/** Una propuesta de la BD, completando lo que falte con la constante del tipo */
function fusionarPropuesta(
bruta: unknown,
constante: ParametrosDesarrollo | undefined,
): PropuestaConfig | null {
if (typeof bruta !== 'object' || bruta === null) return null
const p = bruta as Record<string, unknown>
const tipo = tipoPropuesta(p['tipo'])
if (tipo === null) return null
const base = constante?.propuestas.find((c) => c.tipo === tipo)
const propuesta: PropuestaConfig = {
tipo,
claveDescuento: claveDescuento(p['claveDescuento']) ?? base?.claveDescuento ?? CLAVE_POR_TIPO[tipo],
titulo: texto(p['titulo']) ?? base?.titulo ?? tipo,
descuentoDefault: numero(p['descuentoDefault']) ?? base?.descuentoDefault ?? 0,
pctEnganche: numero(p['pctEnganche']) ?? base?.pctEnganche ?? 0,
engancheBase: basePrecio(p['engancheBase']) ?? base?.engancheBase ?? 'final',
pctPagoFinal: numero(p['pctPagoFinal']) ?? base?.pctPagoFinal ?? 0,
pagoFinalBase: basePrecio(p['pagoFinalBase']) ?? base?.pagoFinalBase ?? 'final',
modoMensualidades: modoMensualidades(p['modoMensualidades']) ?? base?.modoMensualidades ?? 'porcentaje',
}
const ceroSinApartado = p['engancheCeroSinApartado'] ?? base?.engancheCeroSinApartado
if (ceroSinApartado === true) propuesta.engancheCeroSinApartado = true
// Solo se conserva el "false" (desactivada): true/ausente es lo mismo y así una
// configuración igual a la constante da exactamente el mismo objeto
const activa = p['activa'] ?? p['activo'] ?? base?.activa
if (activa === false) propuesta.activa = false
return propuesta
}
/**
* Reglas de un desarrollo que llegan del API, completadas con su constante.
* Devuelve null si el JSON no es usable: el desarrollo se queda con su
* constante (comportamiento de siempre).
*/
export function fusionarReglas(id: string, bruto: unknown): ParametrosDesarrollo | null {
if (typeof bruto !== 'object' || bruto === null || Array.isArray(bruto)) return null
const r = bruto as Record<string, unknown>
const brutoPropuestas = r['propuestas']
if (!Array.isArray(brutoPropuestas) || brutoPropuestas.length === 0) return null
const constante = DESARROLLOS[id]
const propuestas: PropuestaConfig[] = []
for (const bruta of brutoPropuestas) {
const propuesta = fusionarPropuesta(bruta, constante)
if (propuesta === null) return null
propuestas.push(propuesta)
}
const fuenteDeN = r['fuenteDeN']
const params: ParametrosDesarrollo = {
id,
nombre: texto(r['nombre']) ?? constante?.nombre ?? id,
precioUnico:
typeof r['precioUnico'] === 'boolean' ? r['precioUnico'] : (constante?.precioUnico ?? true),
fuenteDeN:
fuenteDeN === 'fechaEntrega' || fuenteDeN === 'fijo' || fuenteDeN === 'porUnidad'
? fuenteDeN
: (constante?.fuenteDeN ?? 'fijo'),
cuotaMantenimiento:
'cuotaMantenimiento' in r
? (numero(r['cuotaMantenimiento']) ?? null)
: (constante?.cuotaMantenimiento ?? null),
prefijoUnidadPdf: texto(r['prefijoUnidadPdf']) ?? constante?.prefijoUnidadPdf ?? 'CASA',
propuestas,
}
const nFijo = numero(r['nFijo']) ?? constante?.nFijo
if (typeof nFijo === 'number') params.nFijo = nFijo
const nDefault = numero(r['nDefault']) ?? constante?.nDefault
if (typeof nDefault === 'number') params.nDefault = nDefault
// Solo se conserva el "false" (desactivado): true/ausente es lo mismo
const activo = r['activoEnCotizador'] ?? r['activo']
if (activo === false) params.activoEnCotizador = false
return params
}
/**
* Convierte el mapa `reglasPorDesarrollo` del API en reglas efectivas. Los
* desarrollos sin reglas usables se omiten (quedan en su constante).
*/
export function reglasDesdeApi(bruto: unknown): ReglasDesarrollo {
const reglas: ReglasDesarrollo = {}
if (typeof bruto !== 'object' || bruto === null) return reglas
for (const [id, valor] of Object.entries(bruto as Record<string, unknown>)) {
const fusionado = fusionarReglas(id, valor)
if (fusionado !== null) reglas[id] = fusionado
}
return reglas
}
/** Reglas del desarrollo: las del admin si las hay; si no, la constante (o DIAGONAL) */
export function desarrolloEfectivo(
reglas: ReglasDesarrollo | undefined,
id?: string | null,
): ParametrosDesarrollo {
if (id !== undefined && id !== null) {
const delDesarrollo = reglas?.[id]
if (delDesarrollo) return delDesarrollo
}
return obtenerDesarrollo(id)
}
/** Descuentos por defecto del desarrollo, indexados por clave de propuesta */
export function descuentosDefaultEfectivo(
reglas: ReglasDesarrollo | undefined,
id?: string | null,
): DescuentosPropuestas {
const params = desarrolloEfectivo(reglas, id)
const descuentos: DescuentosPropuestas = {
creditoBancario: 0,
creditoInfonavit: 0,
contado: 0,
semiContado: 0,
}
for (const propuesta of params.propuestas) descuentos[propuesta.claveDescuento] = propuesta.descuentoDefault
return descuentos
}
/**
* Desarrollos que se pueden ofrecer en el cotizador: los conocidos más los que
* el admin haya configurado, sin los desactivados ni los que se quedaron sin
* propuestas vigentes.
*/
export function idsCotizables(reglas: ReglasDesarrollo = SIN_REGLAS): string[] {
const ids = [
...LISTA_DESARROLLOS,
...Object.keys(reglas).filter((id) => !LISTA_DESARROLLOS.includes(id)),
]
return ids.filter((id) => {
const params = desarrolloEfectivo(reglas, id)
return params.activoEnCotizador !== false && propuestasVigentes(params).length > 0
})
}

View File

@@ -0,0 +1,89 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import CotizadorPage from './CotizadorPage'
import { DESARROLLOS } from '../data/desarrollos'
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
*/
const unidadDiagonal = INVENTARIO_SEED.find((u) => u.desarrollo === 'DIAGONAL')!
const unidadSolana = INVENTARIO_SEED.find((u) => u.desarrollo === 'SOLANA')!
const respuesta = (data: unknown, status = 200) =>
new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json' } })
/** Reglas del API con los ajustes del test (JSON serializado, como en la BD) */
function reglasDeApi(): { reglasPorDesarrollo: Record<string, unknown> } {
const mapa: Record<string, unknown> = {}
for (const [id, params] of Object.entries(DESARROLLOS)) {
mapa[id] = JSON.parse(JSON.stringify(params)) as unknown
}
const solana = mapa['SOLANA'] as Record<string, unknown>
solana['activoEnCotizador'] = false
const diagonal = mapa['DIAGONAL'] as { propuestas: Record<string, unknown>[] }
for (const propuesta of diagonal.propuestas) {
if (propuesta['tipo'] === 'CONTADO') propuesta['activa'] = false
}
return { reglasPorDesarrollo: mapa }
}
beforeEach(() => {
const almacenamiento = new Map<string, string>()
window.history.replaceState({}, '', '/')
vi.stubGlobal('localStorage', {
getItem: (k: string) => almacenamiento.get(k) ?? null,
setItem: (k: string, v: string) => void almacenamiento.set(k, v),
removeItem: (k: string) => void almacenamiento.delete(k),
clear: () => almacenamiento.clear(),
})
vi.stubGlobal(
'fetch',
vi.fn((input: RequestInfo | URL) => {
const url = String(input)
if (url.includes('/desarrollos/reglas-cotizador')) return Promise.resolve(respuesta(reglasDeApi()))
if (url.includes('/unidades')) return Promise.resolve(respuesta([unidadDiagonal, unidadSolana]))
if (url.includes('/cotizaciones/mias')) return Promise.resolve(respuesta([]))
return Promise.reject(new TypeError(`fetch inesperado: ${url}`))
}),
)
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
describe('CotizadorPage con reglas del admin', () => {
it('no ofrece el desarrollo desactivado ni la propuesta desactivada', async () => {
render(
<MemoryRouter>
<CotizadorPage />
</MemoryRouter>,
)
const inputUnidad = (await screen.findByLabelText('Unidad')) as HTMLInputElement
// SOLANA está desactivado: no aparece su botón en el selector de desarrollos
expect(screen.queryByRole('button', { name: /Solana/ })).toBeNull()
expect(screen.getByRole('button', { name: /Diagonal/ })).toBeTruthy()
expect(screen.getByRole('button', { name: /Senda/ })).toBeTruthy()
// Cargar las 4 propuestas de DIAGONAL: solo las 3 vigentes
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('Crédito Infonavit-Cofinavit')).toBeTruthy()
expect(screen.getByText('Semi Contado')).toBeTruthy()
expect(screen.queryByText('Contado')).toBeNull()
})
})

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>

View File

@@ -1,5 +1,5 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { getUnidades } from './api'
import { getReglasCotizador, getUnidades } from './api'
import { INVENTARIO_SEED } from '../data/unidades'
import type { Unidad } from '../types'
@@ -65,3 +65,43 @@ describe('getUnidades', () => {
expect(warn).toHaveBeenCalledOnce()
})
})
describe('getReglasCotizador', () => {
it('devuelve las reglas por desarrollo que manda el API', async () => {
const fetchMock = vi.fn().mockResolvedValue(
respuestaOk({
reglasPorDesarrollo: {
DIAGONAL: { nombre: 'Diagonal', propuestas: [{ tipo: 'CONTADO', descuentoDefault: 0.05 }] },
},
}),
)
vi.stubGlobal('fetch', fetchMock)
const reglas = await getReglasCotizador()
expect(fetchMock).toHaveBeenCalledWith('/api/desarrollos/reglas-cotizador', {})
// Lo que manda el API gana; lo que falta se completa con la constante
expect(reglas['DIAGONAL']?.propuestas[0]?.descuentoDefault).toBe(0.05)
expect(reglas['DIAGONAL']?.propuestas[0]?.pctEnganche).toBe(0.9)
expect(reglas['DIAGONAL']?.nDefault).toBe(13)
})
it('sin respuesta del API devuelve un mapa vacío (todo cae a las constantes)', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(new Response('error', { status: 500 })))
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
const reglas = await getReglasCotizador()
expect(reglas).toEqual({})
expect(warn).toHaveBeenCalledOnce()
})
it('respuesta con reglas corruptas: esas entradas se descartan', async () => {
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(respuestaOk({ reglasPorDesarrollo: { SENDA: 'roto', ATELIER: null } })),
)
expect(await getReglasCotizador()).toEqual({})
})
})

View File

@@ -1,7 +1,13 @@
import type { Unidad, TipoPropuesta } from '../types'
import { INVENTARIO_SEED } from '../data/unidades'
import { reglasDesdeApi, type ReglasDesarrollo } from '../lib/reglas'
import { conAuth, manejar401 } from './auth'
import { guardarUnidades, cargarUnidadesLocal } from './offline-db'
import {
guardarUnidades,
cargarUnidadesLocal,
guardarReglasLocal,
cargarReglasLocal,
} from './offline-db'
// TODO produccion: '/api' same-origin detras de NGINX
const API_URL = import.meta.env.VITE_API_URL || '/api'
@@ -38,6 +44,32 @@ export async function getUnidades(): Promise<GetUnidadesResult> {
}
}
// ---------- Reglas comerciales del cotizador ----------
/**
* Reglas comerciales EFECTIVAS por desarrollo (`GET /desarrollos/reglas-cotizador`):
* las que el admin configuró en la BD, con las constantes de respaldo para los
* desarrollos sin reglas guardadas. Nunca lanza: sin servidor devuelve la
* última copia guardada y, si no hay, un mapa vacío (todo cae a las constantes).
*/
export async function getReglasCotizador(): Promise<ReglasDesarrollo> {
try {
const resp = await fetch(`${API_URL}/desarrollos/reglas-cotizador`, conAuth())
manejar401(resp)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const cuerpo = (await resp.json()) as { reglasPorDesarrollo?: unknown } | null
const reglas = reglasDesdeApi(cuerpo?.reglasPorDesarrollo)
void guardarReglasLocal(reglas)
return reglas
} catch (err) {
console.warn(
'[api] No se pudieron obtener las reglas del cotizador; se usan los valores por defecto.',
err,
)
return reglasDesdeApi(await cargarReglasLocal())
}
}
// ---------- Cotizaciones guardadas (historial del vendedor) ----------
/** Tipo de propuesta tal como lo espera el API (body de POST /cotizaciones) */

View File

@@ -32,6 +32,26 @@ export async function cargarUnidadesLocal(): Promise<unknown> {
}
}
// Las reglas del cotizador se guardan en la misma tienda que el inventario
// (clave propia), sin subir la versión de la BD.
export async function guardarReglasLocal(reglas: unknown) {
try {
const db = await getDB()
await db.put('unidades', reglas, 'reglas')
} catch {
// Sin almacenamiento: la próxima sesión las recarga del API
}
}
export async function cargarReglasLocal(): Promise<unknown> {
try {
const db = await getDB()
return db.get('unidades', 'reglas')
} catch {
return undefined
}
}
export async function guardarCotizaciones(cotizaciones: unknown[]) {
try {
const db = await getDB()

View File

@@ -75,6 +75,11 @@ export interface PropuestaConfig {
modoMensualidades: ModoMensualidades
/** SOLANA Infonavit: si pctEnganche es 0, el enganche es 0 (no se resta el apartado) */
engancheCeroSinApartado?: boolean
/**
* Regla del admin: false => la propuesta no se ofrece ni se puede elegir en
* el cotizador (tampoco se calcula en el API). Ausente/true => vigente.
*/
activa?: boolean
}
export interface ParametrosDesarrollo {
@@ -96,5 +101,10 @@ export interface ParametrosDesarrollo {
cuotaMantenimiento: number | null
/** Prefijo de la unidad en el PDF ('CASA', 'DEPTO') */
prefijoUnidadPdf: string
/**
* Regla del admin: false => el desarrollo no se ofrece en el cotizador (el
* API también rechaza cotizarlo). Ausente/true => disponible.
*/
activoEnCotizador?: boolean
propuestas: PropuestaConfig[]
}

View File

@@ -8,16 +8,30 @@ import { ConfigPage } from './components/config/ConfigPage'
import { CapturaPage } from './pages/CapturaPage'
import { ClientesMaestroPage } from './pages/ClientesMaestroPage'
import { SoportePage } from './pages/SoportePage'
import { DesarrollosPage } from './pages/DesarrollosPage'
import { DesarrolloPublicoPage } from './pages/DesarrolloPublicoPage'
import { getToken, type UsuarioSesion } from './services/auth'
import { getMe } from './services/api'
type Vista = 'home' | 'contactos' | 'captura' | 'clientes-maestro' | 'soporte' | 'configuraciones'
type Vista = 'home' | 'contactos' | 'captura' | 'clientes-maestro' | 'soporte' | 'desarrollos' | 'configuraciones'
/**
* Micrositio público: NGINX sirve la landing en cualquier ruta, así que el deep
* link /d/<clave> (el que se comparte con el cliente final) se resuelve aquí y
* se renderiza sin navbar ni sesión.
*/
function clavePublicaDeRuta(ruta: string): string | null {
const match = /^\/d\/([^/]+)\/?$/.exec(ruta)
return match === null ? null : decodeURIComponent(match[1])
}
export default function App() {
const [vista, setVista] = useState<Vista>('home')
// Módulos permitidos por el rol (GET /auth/me); null = sin sesión → se muestran todos
const [permitidos, setPermitidos] = useState<string[] | null>(null)
const [usuario, setUsuario] = useState<UsuarioSesion | null>(null)
// Se evalúa una sola vez al montar: el micrositio no cambia de ruta internamente
const [clavePublica] = useState(() => clavePublicaDeRuta(window.location.pathname))
useEffect(() => {
let cancelado = false
@@ -45,6 +59,10 @@ export default function App() {
}
}, [])
if (clavePublica !== null) {
return <DesarrolloPublicoPage clave={clavePublica} />
}
if (vista === 'contactos') {
return (
<div className="flex min-h-screen flex-col">
@@ -85,6 +103,16 @@ export default function App() {
)
}
if (vista === 'desarrollos' && usuario?.rol === 'admin') {
return (
<div className="flex min-h-screen flex-col">
<Navbar usuario={usuario} onConfiguraciones={() => setVista('configuraciones')} />
<DesarrollosPage onVolver={() => setVista('home')} />
</div>
)
}
if (vista === 'configuraciones' && (usuario?.rol === 'admin' || usuario?.rol === 'manager')) {
return (
<div className="flex min-h-screen flex-col">
@@ -128,7 +156,9 @@ export default function App() {
? () => setVista('clientes-maestro')
: modulo.id === 'soporte'
? () => setVista('soporte')
: undefined
: modulo.id === 'desarrollos'
? () => setVista('desarrollos')
: undefined
}
/>
))}

View File

@@ -16,6 +16,10 @@ const MODULOS_DISPONIBLES = [
'mi-tablero',
'tablero-comercial',
'contactos',
'clientes-maestro',
'captura',
'soporte',
'desarrollos',
]
const ACCIONES_DISPONIBLES = [
@@ -48,6 +52,10 @@ const MODULO_LABELS: Record<string, string> = {
'mi-tablero': 'Mi tablero',
'tablero-comercial': 'Tablero comercial',
contactos: 'Contactos',
'clientes-maestro': 'Clientes maestro',
captura: 'Captura',
soporte: 'Soporte',
desarrollos: 'Desarrollos (solo admin)',
}
interface RolEstado {

View File

@@ -0,0 +1,345 @@
import { useCallback, useEffect, useState } from 'react'
import {
eliminarDesarrollo,
ErrorApi,
obtenerDesarrollo,
type DesarrolloFicha,
} from '../../services/desarrollos'
import {
BadgeEstado,
BadgeEstatus,
BOTON_MINIMO,
BOTON_PELIGRO,
BOTON_SECUNDARIO,
Chip,
ETIQUETA_ESTADO,
ETIQUETA_TIPO,
MensajeError,
MensajeExito,
TARJETA,
} from './comun'
import { FormularioDesarrollo } from './FormularioDesarrollo'
import { TabInventario } from './TabInventario'
import { TabModelos } from './TabModelos'
import { TabReglas } from './TabReglas'
import Skeleton from '../Skeleton'
type Tab = 'datos' | 'modelos' | 'inventario' | 'reglas'
const TABS: { key: Tab; label: string }[] = [
{ key: 'datos', label: 'Datos' },
{ key: 'modelos', label: 'Modelos' },
{ key: 'inventario', label: 'Inventario' },
{ key: 'reglas', label: 'Reglas del cotizador' },
]
const fecha = (valor: string | null | undefined) => (valor ? valor.slice(0, 10) : '—')
const texto = (valor: string | null | undefined) => (valor && valor.trim() !== '' ? valor : '—')
function Dato({ etiqueta, valor }: { etiqueta: string; valor: string }) {
return (
<div>
<dt className="text-[11px] text-[#94A3B8] dark:text-[#64748B]">{etiqueta}</dt>
<dd className="text-sm text-[#111827] dark:text-[#E2E8F0]">{valor}</dd>
</div>
)
}
interface Props {
desarrolloId: string
onVolver: () => void
/** Avisa al listado que los datos del desarrollo cambiaron */
onCambio: () => void
}
/** Ficha de un desarrollo con las pestañas de datos, modelos, inventario y reglas */
export function FichaDesarrollo({ desarrolloId, onVolver, onCambio }: Props) {
const [ficha, setFicha] = useState<DesarrolloFicha | null>(null)
const [cargando, setCargando] = useState(true)
const [error, setError] = useState('')
const [tab, setTab] = useState<Tab>('datos')
const [editando, setEditando] = useState(false)
const [errorEliminar, setErrorEliminar] = useState('')
const [sugerenciaEliminar, setSugerenciaEliminar] = useState('')
const [exitoDatos, setExitoDatos] = useState('')
const [eliminando, setEliminando] = useState(false)
const cargar = useCallback(async () => {
setCargando(true)
setError('')
try {
setFicha(await obtenerDesarrollo(desarrolloId))
} catch (err) {
setError(
err instanceof Error && err.message
? err.message
: 'No se pudo cargar la ficha del desarrollo.',
)
} finally {
setCargando(false)
}
}, [desarrolloId])
useEffect(() => {
void cargar()
}, [cargar])
const borrar = async (actual: DesarrolloFicha) => {
if (
typeof window !== 'undefined' &&
!window.confirm(`¿Eliminar el desarrollo "${actual.nombre}"? Esta acción no se puede deshacer.`)
) {
return
}
setErrorEliminar('')
setSugerenciaEliminar('')
setExitoDatos('')
setEliminando(true)
try {
await eliminarDesarrollo(actual.id)
onCambio()
onVolver()
} catch (err) {
setErrorEliminar(
err instanceof Error && err.message
? err.message
: 'No se pudo eliminar el desarrollo.',
)
if (err instanceof ErrorApi && err.status === 409) {
setSugerenciaEliminar(
'Para dejar de operarlo sin perder su historial, cambia su estado a CERRADO y desactiva su visibilidad en cotizador y altas.',
)
}
} finally {
setEliminando(false)
}
}
if (cargando) {
return (
<div className={`${TARJETA} p-6`}>
<Skeleton className="mb-3 h-5 w-1/3" />
<Skeleton className="mb-2 h-3 w-2/3" />
<Skeleton className="h-3 w-1/2" />
</div>
)
}
if (error || !ficha) {
return (
<div className={`${TARJETA} p-6`}>
<MensajeError>{error || 'No se encontró el desarrollo.'}</MensajeError>
<div className="mt-4 flex gap-3">
<button type="button" onClick={() => void cargar()} className={BOTON_SECUNDARIO}>
Reintentar
</button>
<button type="button" onClick={onVolver} className={BOTON_SECUNDARIO}>
Volver al listado
</button>
</div>
</div>
)
}
return (
<div className="space-y-4">
<div className="flex flex-wrap items-start gap-4">
{ficha.logoUrl && (
<img
src={ficha.logoUrl}
alt={`Logo de ${ficha.nombre}`}
className="h-12 w-12 rounded-lg border border-[#E5E7EB] object-contain dark:border-[#334155]"
/>
)}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-3">
<h1 className="text-2xl font-semibold tracking-tight text-[#111827] dark:text-[#E2E8F0]">
{ficha.nombre}
</h1>
<BadgeEstado estado={ficha.estado} />
</div>
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
{ficha.clave} · {ETIQUETA_TIPO[ficha.tipo] ?? ficha.tipo} ·{' '}
{ficha.unidadesDisponibles ?? 0} de {ficha.unidadesTotales ?? 0} unidades disponibles
</p>
<div className="mt-2 flex flex-wrap gap-1.5">
{ficha.activoEnCotizador ? <Chip>En cotizador</Chip> : <Chip>Oculto en cotizador</Chip>}
{ficha.activoEnAlta ? <Chip>En altas</Chip> : <Chip>Oculto en altas</Chip>}
{!ficha.tieneReglas && <Chip>Sin reglas del cotizador</Chip>}
</div>
</div>
<button type="button" onClick={onVolver} className={`${BOTON_SECUNDARIO} order-first`}>
← Volver al listado
</button>
</div>
<nav aria-label="Secciones de la ficha del desarrollo">
<ul
role="tablist"
className="flex flex-wrap gap-1 rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] p-1 dark:border-[#1E293B] dark:bg-[#1E293B]"
>
{TABS.map((t) => (
<li key={t.key}>
<button
type="button"
role="tab"
id={`tab-${t.key}`}
aria-selected={tab === t.key}
aria-controls={`panel-${t.key}`}
aria-label={t.label}
onClick={() => setTab(t.key)}
className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
tab === t.key
? 'bg-white text-[#111827] shadow-sm dark:bg-[#1E293B] dark:text-[#E2E8F0]'
: 'text-[#64748B] hover:text-[#374151] dark:text-[#94A3B8] dark:hover:text-[#CBD5E1]'
}`}
>
{t.label}
</button>
</li>
))}
</ul>
</nav>
<section id={`panel-${tab}`} role="tabpanel" aria-labelledby={`tab-${tab}`} className="space-y-4">
{tab === 'datos' && (
<>
{editando ? (
<div className={`${TARJETA} p-4`}>
<h2 className="mb-3 text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
Editar datos del desarrollo
</h2>
<FormularioDesarrollo
inicial={ficha}
idPrefijo="dev-ficha"
onGuardado={(actualizada) => {
setFicha(actualizada)
setEditando(false)
setExitoDatos('Datos del desarrollo actualizados.')
onCambio()
}}
onCancelar={() => setEditando(false)}
/>
</div>
) : (
<>
<div className={`${TARJETA} p-4`}>
<div className="flex flex-wrap items-center justify-between gap-3">
<h2 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
Datos del desarrollo
</h2>
<button
type="button"
onClick={() => {
setEditando(true)
setExitoDatos('')
}}
className={BOTON_MINIMO}
>
Editar
</button>
</div>
<dl className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Dato etiqueta="Clave" valor={ficha.clave} />
<Dato etiqueta="Estado" valor={ETIQUETA_ESTADO[ficha.estado] ?? ficha.estado} />
<Dato etiqueta="Tipo" valor={ETIQUETA_TIPO[ficha.tipo] ?? ficha.tipo} />
<Dato etiqueta="Zona" valor={texto(ficha.zona)} />
<Dato etiqueta="Dirección" valor={texto(ficha.direccion)} />
<Dato etiqueta="Constructora" valor={texto(ficha.constructora)} />
<Dato etiqueta="Inicio de obra" valor={fecha(ficha.fechaInicioObra)} />
<Dato etiqueta="Entrega programada" valor={fecha(ficha.fechaEntregaProgramada)} />
<Dato
etiqueta="Entrega reprogramada"
valor={fecha(ficha.fechaEntregaReprogramada)}
/>
<Dato etiqueta="Prefijo en PDF" valor={texto(ficha.prefijoUnidadPdf)} />
<Dato
etiqueta="Visibilidad"
valor={`${ficha.activoEnCotizador ? 'Cotizador' : 'Sin cotizador'} · ${
ficha.activoEnAlta ? 'Altas' : 'Sin altas'
}`}
/>
</dl>
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<p className="text-[11px] text-[#94A3B8] dark:text-[#64748B]">Descripción</p>
<p className="whitespace-pre-line text-sm text-[#111827] dark:text-[#E2E8F0]">
{texto(ficha.descripcion)}
</p>
</div>
<div>
<p className="text-[11px] text-[#94A3B8] dark:text-[#64748B]">Amenidades</p>
<p className="whitespace-pre-line text-sm text-[#111827] dark:text-[#E2E8F0]">
{texto(ficha.amenidades)}
</p>
</div>
</div>
{ficha.imagenUrl && (
<img
src={ficha.imagenUrl}
alt={`Imagen de ${ficha.nombre}`}
className="mt-4 max-h-48 w-auto max-w-full rounded-lg border border-[#E5E7EB] object-contain dark:border-[#334155]"
/>
)}
{Object.keys(ficha.conteosPorEstatus ?? {}).length > 0 && (
<div className="mt-4">
<p className="text-[11px] font-medium uppercase tracking-wider text-[#94A3B8] dark:text-[#64748B]">
Inventario por estatus
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
{Object.entries(ficha.conteosPorEstatus).map(([estatus, cantidad]) => (
<span key={estatus} className="flex items-center gap-1.5">
<BadgeEstatus estatus={estatus} />
<span className="text-xs font-medium text-[#475569] dark:text-[#CBD5E1]">
{cantidad}
</span>
</span>
))}
</div>
</div>
)}
</div>
<div className={`${TARJETA} p-4`}>
<h2 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
Zona de riesgo
</h2>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
Eliminar el desarrollo solo es posible si no tiene unidades, modelos ni ventas
asociadas.
</p>
<div className="mt-3 flex flex-col gap-3">
<button
type="button"
onClick={() => void borrar(ficha)}
disabled={eliminando}
className={`${BOTON_PELIGRO} self-start`}
>
{eliminando ? 'Eliminando…' : 'Eliminar desarrollo'}
</button>
{errorEliminar && <MensajeError>{errorEliminar}</MensajeError>}
{sugerenciaEliminar && (
<p className="text-xs text-[#64748B] dark:text-[#94A3B8]">
{sugerenciaEliminar}
</p>
)}
</div>
</div>
</>
)}
{exitoDatos && <MensajeExito>{exitoDatos}</MensajeExito>}
</>
)}
{tab === 'modelos' && <TabModelos desarrollo={ficha} onRefrescar={cargar} />}
{tab === 'inventario' && <TabInventario desarrollo={ficha} onRefrescar={cargar} />}
{tab === 'reglas' && <TabReglas desarrollo={ficha} onRefrescar={cargar} />}
</section>
</div>
)
}

View File

@@ -0,0 +1,458 @@
import { useState, type FormEvent } from 'react'
import {
actualizarDesarrollo,
crearDesarrollo,
type DatosDesarrollo,
type DesarrolloFicha,
type EstadoDesarrollo,
type TipoDesarrollo,
} from '../../services/desarrollos'
import {
AYUDA,
BOTON_PRIMARIO,
BOTON_SECUNDARIO,
Campo,
ETIQUETA_ESTADO,
ETIQUETA_TIPO,
INPUT,
INPUT_ERROR,
MensajeError,
MensajeExito,
TARJETA,
} from './comun'
const CLAVE_RE = /^[A-Z0-9-]{2,20}$/
const MENSAJE_CLAVE =
'La clave debe tener de 2 a 20 caracteres: mayúsculas, números y guiones (p. ej. SENDA-401).'
const ESTADOS: EstadoDesarrollo[] = ['ACTIVO', 'PREVENTA', 'CERRADO']
const TIPOS: TipoDesarrollo[] = ['VERTICAL', 'HORIZONTAL']
interface FormEstado {
clave: string
nombre: string
estado: EstadoDesarrollo
tipo: TipoDesarrollo
zona: string
direccion: string
constructora: string
descripcion: string
amenidades: string
fechaInicioObra: string
fechaEntregaProgramada: string
fechaEntregaReprogramada: string
activoEnCotizador: boolean
activoEnAlta: boolean
prefijoUnidadPdf: string
imagenUrl: string
logoUrl: string
}
function estadoInicial(ficha?: DesarrolloFicha | null): FormEstado {
return {
clave: ficha?.clave ?? '',
nombre: ficha?.nombre ?? '',
estado: ficha?.estado ?? 'PREVENTA',
tipo: ficha?.tipo ?? 'HORIZONTAL',
zona: ficha?.zona ?? '',
direccion: ficha?.direccion ?? '',
constructora: ficha?.constructora ?? '',
descripcion: ficha?.descripcion ?? '',
amenidades: ficha?.amenidades ?? '',
fechaInicioObra: ficha?.fechaInicioObra?.slice(0, 10) ?? '',
fechaEntregaProgramada: ficha?.fechaEntregaProgramada?.slice(0, 10) ?? '',
fechaEntregaReprogramada: ficha?.fechaEntregaReprogramada?.slice(0, 10) ?? '',
activoEnCotizador: ficha?.activoEnCotizador ?? true,
activoEnAlta: ficha?.activoEnAlta ?? true,
prefijoUnidadPdf: ficha?.prefijoUnidadPdf ?? '',
imagenUrl: ficha?.imagenUrl ?? '',
logoUrl: ficha?.logoUrl ?? '',
}
}
const nulo = (valor: string) => (valor.trim() === '' ? null : valor.trim())
function VistaPrevia({ url, etiqueta }: { url: string; etiqueta: string }) {
const [fallo, setFallo] = useState(false)
if (url.trim() === '') return null
return (
<div className="mt-2">
{fallo ? (
<p className={AYUDA}>No se pudo cargar la vista previa de {etiqueta}.</p>
) : (
<img
src={url}
alt={`Vista previa de ${etiqueta}`}
onError={() => setFallo(true)}
className="max-h-24 w-auto max-w-full rounded-lg border border-[#E5E7EB] object-contain dark:border-[#334155]"
/>
)}
</div>
)
}
interface Props {
/** Ficha a editar; sin ficha el formulario da de alta */
inicial?: DesarrolloFicha | null
/** Prefijo de ids para que varios formularios convivan en la misma pantalla */
idPrefijo?: string
onGuardado: (ficha: DesarrolloFicha) => void
onCancelar?: () => void
}
/**
* Alta y edición de un desarrollo. Se usa dentro del modal del listado y en la
* pestaña "Datos" de la ficha, así que no lleva encabezado propio.
*/
export function FormularioDesarrollo({
inicial = null,
idPrefijo = 'dev',
onGuardado,
onCancelar,
}: Props) {
const [form, setForm] = useState<FormEstado>(() => estadoInicial(inicial))
const [errores, setErrores] = useState<Record<string, string>>({})
const [errorServidor, setErrorServidor] = useState('')
const [exito, setExito] = useState('')
const [guardando, setGuardando] = useState(false)
const id = (campo: string) => `${idPrefijo}-${campo}`
const set = <K extends keyof FormEstado>(campo: K, valor: FormEstado[K]) =>
setForm((prev) => ({ ...prev, [campo]: valor }))
const errorClave =
form.clave !== '' && !CLAVE_RE.test(form.clave)
? MENSAJE_CLAVE
: errores.clave
const validar = (): Record<string, string> => {
const nuevos: Record<string, string> = {}
const clave = form.clave.trim()
if (clave === '') nuevos.clave = 'La clave es obligatoria.'
else if (!CLAVE_RE.test(clave)) nuevos.clave = MENSAJE_CLAVE
if (form.nombre.trim().length < 3) nuevos.nombre = 'Escribe el nombre del desarrollo (mínimo 3 caracteres).'
return nuevos
}
const enviar = async (evento: FormEvent) => {
evento.preventDefault()
setErrorServidor('')
setExito('')
const nuevos = validar()
setErrores(nuevos)
if (Object.keys(nuevos).length > 0) return
const datos: DatosDesarrollo = {
clave: form.clave.trim(),
nombre: form.nombre.trim(),
estado: form.estado,
tipo: form.tipo,
activoEnCotizador: form.activoEnCotizador,
activoEnAlta: form.activoEnAlta,
zona: nulo(form.zona),
direccion: nulo(form.direccion),
constructora: nulo(form.constructora),
descripcion: nulo(form.descripcion),
amenidades: nulo(form.amenidades),
fechaInicioObra: nulo(form.fechaInicioObra),
fechaEntregaProgramada: nulo(form.fechaEntregaProgramada),
fechaEntregaReprogramada: nulo(form.fechaEntregaReprogramada),
prefijoUnidadPdf: nulo(form.prefijoUnidadPdf),
imagenUrl: nulo(form.imagenUrl),
logoUrl: nulo(form.logoUrl),
}
setGuardando(true)
try {
const ficha = inicial
? await actualizarDesarrollo(inicial.id, datos)
: await crearDesarrollo(datos)
setExito(inicial ? 'Cambios guardados.' : `Desarrollo "${datos.clave}" creado.`)
onGuardado(ficha)
} catch (err) {
setErrorServidor(
err instanceof Error && err.message
? err.message
: 'No se pudo guardar el desarrollo.',
)
} finally {
setGuardando(false)
}
}
return (
<form onSubmit={enviar} className="space-y-4" noValidate>
{/* Identidad */}
<section className={`${TARJETA} p-4`} aria-labelledby={id('titulo-identidad')}>
<h3 id={id('titulo-identidad')} className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
Identidad
</h3>
<div className="mt-3 grid gap-4 sm:grid-cols-2">
<Campo
id={id('clave')}
etiqueta="Clave"
requerido
ayuda="Forma los códigos de unidad del inventario, p. ej. SENDA-401-{n}."
error={errorClave}
>
<input
id={id('clave')}
type="text"
value={form.clave}
maxLength={20}
aria-invalid={errorClave ? true : undefined}
onChange={(e) => set('clave', e.target.value.toUpperCase())}
placeholder="SENDA-401"
className={`${INPUT} ${errorClave ? INPUT_ERROR : ''}`}
/>
</Campo>
<Campo
id={id('nombre')}
etiqueta="Nombre"
requerido
error={errores.nombre}
>
<input
id={id('nombre')}
type="text"
value={form.nombre}
aria-invalid={errores.nombre ? true : undefined}
onChange={(e) => set('nombre', e.target.value)}
placeholder="Senda Residencial"
className={`${INPUT} ${errores.nombre ? INPUT_ERROR : ''}`}
/>
</Campo>
<Campo id={id('estado')} etiqueta="Estado">
<select
id={id('estado')}
value={form.estado}
onChange={(e) => set('estado', e.target.value as EstadoDesarrollo)}
className={INPUT}
>
{ESTADOS.map((opcion) => (
<option key={opcion} value={opcion}>
{ETIQUETA_ESTADO[opcion]}
</option>
))}
</select>
</Campo>
<Campo id={id('tipo')} etiqueta="Tipo">
<select
id={id('tipo')}
value={form.tipo}
onChange={(e) => set('tipo', e.target.value as TipoDesarrollo)}
className={INPUT}
>
{TIPOS.map((opcion) => (
<option key={opcion} value={opcion}>
{ETIQUETA_TIPO[opcion]}
</option>
))}
</select>
</Campo>
</div>
</section>
{/* Ubicación */}
<section className={`${TARJETA} p-4`} aria-labelledby={id('titulo-ubicacion')}>
<h3 id={id('titulo-ubicacion')} className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
Ubicación
</h3>
<div className="mt-3 grid gap-4 sm:grid-cols-2">
<Campo id={id('zona')} etiqueta="Zona">
<input
id={id('zona')}
type="text"
value={form.zona}
onChange={(e) => set('zona', e.target.value)}
placeholder="Zona Norte"
className={INPUT}
/>
</Campo>
<Campo id={id('constructora')} etiqueta="Constructora">
<input
id={id('constructora')}
type="text"
value={form.constructora}
onChange={(e) => set('constructora', e.target.value)}
placeholder="Constructora Frasa"
className={INPUT}
/>
</Campo>
<Campo id={id('direccion')} etiqueta="Dirección" className="sm:col-span-2">
<input
id={id('direccion')}
type="text"
value={form.direccion}
onChange={(e) => set('direccion', e.target.value)}
placeholder="Av. Reforma 123, Col. Centro"
className={INPUT}
/>
</Campo>
</div>
</section>
{/* Comercial */}
<section className={`${TARJETA} p-4`} aria-labelledby={id('titulo-comercial')}>
<h3 id={id('titulo-comercial')} className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
Comercial
</h3>
<div className="mt-3 grid gap-4">
<Campo id={id('descripcion')} etiqueta="Descripción">
<textarea
id={id('descripcion')}
rows={3}
value={form.descripcion}
onChange={(e) => set('descripcion', e.target.value)}
className={INPUT}
/>
</Campo>
<Campo
id={id('amenidades')}
etiqueta="Amenidades"
ayuda="Una por línea o separadas por comas."
>
<textarea
id={id('amenidades')}
rows={3}
value={form.amenidades}
onChange={(e) => set('amenidades', e.target.value)}
className={INPUT}
/>
</Campo>
</div>
</section>
{/* Fechas clave */}
<section className={`${TARJETA} p-4`} aria-labelledby={id('titulo-fechas')}>
<h3 id={id('titulo-fechas')} className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
Fechas clave
</h3>
<div className="mt-3 grid gap-4 sm:grid-cols-3">
<Campo id={id('fechaInicioObra')} etiqueta="Inicio de obra">
<input
id={id('fechaInicioObra')}
type="date"
value={form.fechaInicioObra}
onChange={(e) => set('fechaInicioObra', e.target.value)}
className={INPUT}
/>
</Campo>
<Campo id={id('fechaEntregaProgramada')} etiqueta="Entrega programada">
<input
id={id('fechaEntregaProgramada')}
type="date"
value={form.fechaEntregaProgramada}
onChange={(e) => set('fechaEntregaProgramada', e.target.value)}
className={INPUT}
/>
</Campo>
<Campo id={id('fechaEntregaReprogramada')} etiqueta="Entrega reprogramada">
<input
id={id('fechaEntregaReprogramada')}
type="date"
value={form.fechaEntregaReprogramada}
onChange={(e) => set('fechaEntregaReprogramada', e.target.value)}
className={INPUT}
/>
</Campo>
</div>
</section>
{/* Visibilidad */}
<section className={`${TARJETA} p-4`} aria-labelledby={id('titulo-visibilidad')}>
<h3 id={id('titulo-visibilidad')} className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
Visibilidad
</h3>
<div className="mt-3 flex flex-col gap-3 sm:flex-row sm:gap-8">
<label htmlFor={id('activoEnCotizador')} className="flex items-center gap-2 text-sm text-[#374151] dark:text-[#CBD5E1]">
<input
id={id('activoEnCotizador')}
type="checkbox"
checked={form.activoEnCotizador}
onChange={(e) => set('activoEnCotizador', e.target.checked)}
className="h-4 w-4 rounded border-[#D1D5DB] text-accent focus:ring-2 focus:ring-accent/20 dark:border-[#334155]"
/>
Aparece en el cotizador
</label>
<label htmlFor={id('activoEnAlta')} className="flex items-center gap-2 text-sm text-[#374151] dark:text-[#CBD5E1]">
<input
id={id('activoEnAlta')}
type="checkbox"
checked={form.activoEnAlta}
onChange={(e) => set('activoEnAlta', e.target.checked)}
className="h-4 w-4 rounded border-[#D1D5DB] text-accent focus:ring-2 focus:ring-accent/20 dark:border-[#334155]"
/>
Aparece en altas
</label>
</div>
</section>
{/* Presentación */}
<section className={`${TARJETA} p-4`} aria-labelledby={id('titulo-presentacion')}>
<h3 id={id('titulo-presentacion')} className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
Presentación
</h3>
<div className="mt-3 grid gap-4 sm:grid-cols-2">
<Campo id={id('imagenUrl')} etiqueta="URL de imagen">
<input
id={id('imagenUrl')}
type="url"
value={form.imagenUrl}
onChange={(e) => set('imagenUrl', e.target.value)}
placeholder="https://…/render.jpg"
className={INPUT}
/>
<VistaPrevia url={form.imagenUrl} etiqueta="la imagen" />
</Campo>
<Campo id={id('logoUrl')} etiqueta="URL de logo">
<input
id={id('logoUrl')}
type="url"
value={form.logoUrl}
onChange={(e) => set('logoUrl', e.target.value)}
placeholder="https://…/logo.png"
className={INPUT}
/>
<VistaPrevia url={form.logoUrl} etiqueta="el logo" />
</Campo>
<Campo
id={id('prefijoUnidadPdf')}
etiqueta="Prefijo de unidad en el PDF"
ayuda="Cómo se nombra la unidad en la cotización impresa (CASA, DEPTO…)."
>
<input
id={id('prefijoUnidadPdf')}
type="text"
value={form.prefijoUnidadPdf}
onChange={(e) => set('prefijoUnidadPdf', e.target.value)}
placeholder="CASA"
className={INPUT}
/>
</Campo>
</div>
</section>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<button type="submit" disabled={guardando} className={BOTON_PRIMARIO}>
{guardando ? 'Guardando…' : inicial ? 'Guardar cambios' : 'Crear desarrollo'}
</button>
{onCancelar && (
<button type="button" onClick={onCancelar} className={BOTON_SECUNDARIO}>
Cancelar
</button>
)}
<div className="sm:flex-1">
{exito && <MensajeExito>{exito}</MensajeExito>}
{errorServidor && <MensajeError>{errorServidor}</MensajeError>}
</div>
</div>
</form>
)
}
export { CLAVE_RE }

View File

@@ -0,0 +1,137 @@
import { useState } from 'react'
import type { DesarrolloResumen, EstadoDesarrollo } from '../../services/desarrollos'
import { BOTON_PRIMARIO, BadgeEstado, INPUT, LABEL, TARJETA } from './comun'
import { TarjetaDesarrollo } from './TarjetaDesarrollo'
import Skeleton from '../Skeleton'
const ESTADOS: EstadoDesarrollo[] = ['ACTIVO', 'PREVENTA', 'CERRADO']
interface Props {
desarrollos: DesarrolloResumen[]
cargando: boolean
onNuevo: () => void
onAbrir: (desarrollo: DesarrolloResumen) => void
onEditar: (desarrollo: DesarrolloResumen) => void
}
/** Listado de desarrollos en tarjetas, con buscador y filtro por estado */
export function ListaDesarrollos({ desarrollos, cargando, onNuevo, onAbrir, onEditar }: Props) {
const [busqueda, setBusqueda] = useState('')
const [estado, setEstado] = useState<'' | EstadoDesarrollo>('')
const termino = busqueda.trim().toLowerCase()
const filtrados = desarrollos.filter((desarrollo) => {
const coincideTexto =
termino === '' ||
desarrollo.nombre.toLowerCase().includes(termino) ||
desarrollo.clave.toLowerCase().includes(termino)
const coincideEstado = estado === '' || desarrollo.estado === estado
return coincideTexto && coincideEstado
})
return (
<div className="space-y-4">
<div className={`${TARJETA} p-4`}>
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div className="grid flex-1 gap-3 sm:grid-cols-2">
<div>
<label htmlFor="buscar-desarrollo" className={LABEL}>
Buscar desarrollo
</label>
<input
id="buscar-desarrollo"
type="search"
value={busqueda}
onChange={(e) => setBusqueda(e.target.value)}
placeholder="Nombre o clave…"
className={INPUT}
/>
</div>
<div>
<label htmlFor="filtro-estado" className={LABEL}>
Estado
</label>
<select
id="filtro-estado"
value={estado}
onChange={(e) => setEstado(e.target.value as '' | EstadoDesarrollo)}
className={INPUT}
>
<option value="">Todos los estados</option>
{ESTADOS.map((opcion) => (
<option key={opcion} value={opcion}>
{opcion}
</option>
))}
</select>
</div>
</div>
<button type="button" onClick={onNuevo} className={BOTON_PRIMARIO}>
+ Nuevo desarrollo
</button>
</div>
</div>
{cargando ? (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className={`${TARJETA} p-4`}>
<Skeleton className="mb-3 h-4 w-1/2" />
<Skeleton className="mb-2 h-3 w-2/3" />
<Skeleton className="h-3 w-1/3" />
</div>
))}
</div>
) : desarrollos.length === 0 ? (
<div className={`${TARJETA} p-8 text-center`}>
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">
Aún no hay desarrollos
</p>
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
Crea el primero para capturar sus modelos, inventario y reglas del cotizador.
</p>
</div>
) : filtrados.length === 0 ? (
<div className={`${TARJETA} p-8 text-center`}>
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
No se encontraron desarrollos con ese filtro.
</p>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{filtrados.map((desarrollo) => (
<TarjetaDesarrollo
key={desarrollo.id}
desarrollo={desarrollo}
onAbrir={onAbrir}
onEditar={onEditar}
/>
))}
</div>
)}
{!cargando && desarrollos.length > 0 && (
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-4">
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
{filtrados.length} de {desarrollos.length} desarrollos
</p>
<div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-[#94A3B8] dark:text-[#64748B]">Conteo por estado:</span>
{ESTADOS.map((opcion) => {
const cantidad = desarrollos.filter((d) => d.estado === opcion).length
if (cantidad === 0) return null
return (
<span key={opcion} className="flex items-center gap-1.5">
<BadgeEstado estado={opcion} />
<span className="text-xs font-medium text-[#475569] dark:text-[#CBD5E1]">
{cantidad}
</span>
</span>
)
})}
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,64 @@
import { useEffect } from 'react'
import { X } from 'lucide-react'
import type { DesarrolloFicha } from '../../services/desarrollos'
import { FormularioDesarrollo } from './FormularioDesarrollo'
interface Props {
/** Sin ficha el modal da de alta; con ficha edita */
desarrollo?: DesarrolloFicha | null
onCerrar: () => void
onGuardado: (ficha: DesarrolloFicha) => void
}
/** Alta/edición de un desarrollo en una ventana modal */
export function ModalDesarrollo({ desarrollo = null, onCerrar, onGuardado }: Props) {
useEffect(() => {
const alPresionar = (evento: KeyboardEvent) => {
if (evento.key === 'Escape') onCerrar()
}
window.addEventListener('keydown', alPresionar)
return () => window.removeEventListener('keydown', alPresionar)
}, [onCerrar])
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/40 px-4 py-8"
role="dialog"
aria-modal="true"
aria-labelledby="titulo-modal-desarrollo"
>
<div className="w-full max-w-3xl rounded-2xl border border-[#E5E7EB] bg-[#F8FAFC] p-4 shadow-xl sm:p-6 dark:border-[#1E293B] dark:bg-[#0F172A]">
<div className="mb-4 flex items-start justify-between gap-4">
<div>
<h2
id="titulo-modal-desarrollo"
className="text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]"
>
{desarrollo ? `Editar ${desarrollo.nombre}` : 'Nuevo desarrollo'}
</h2>
<p className="mt-0.5 text-sm text-[#64748B] dark:text-[#94A3B8]">
{desarrollo
? `Clave ${desarrollo.clave}`
: 'Captura la identidad, ubicación y visibilidad del desarrollo.'}
</p>
</div>
<button
type="button"
onClick={onCerrar}
aria-label="Cerrar"
className="rounded-lg p-1 text-[#64748B] transition-colors hover:bg-[#F1F5F9] dark:text-[#94A3B8] dark:hover:bg-[#1E293B]"
>
<X className="h-5 w-5" />
</button>
</div>
<FormularioDesarrollo
inicial={desarrollo}
idPrefijo="dev-modal"
onGuardado={onGuardado}
onCancelar={onCerrar}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,761 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import {
crearUnidades,
eliminarUnidad,
estatusDe,
listarUnidades,
type DesarrolloFicha,
type UnidadDesarrollo,
type UnidadManual,
} from '../../services/desarrollos'
import {
aNumero,
AYUDA,
BadgeEstatus,
BOTON_MINIMO,
BOTON_PELIGRO,
BOTON_PRIMARIO,
BOTON_SECUNDARIO,
Campo,
ESTATUS_UNIDAD,
INPUT,
MensajeError,
MensajeExito,
moneda,
TARJETA,
} from './comun'
import { generarCodigos, totalDelRango, validarGenerador } from './generador'
import Skeleton from '../Skeleton'
interface Props {
desarrollo: DesarrolloFicha
onRefrescar: () => Promise<void> | void
}
type Modo = 'generador' | 'manual'
interface FilaManual {
codigo: string
manzana: string
lote: string
nivel: string
modeloId: string
precioLista: string
precioAcabados: string
supConstruccion: string
}
const filaVacia = (): FilaManual => ({
codigo: '',
manzana: '',
lote: '',
nivel: '',
modeloId: '',
precioLista: '',
precioAcabados: '',
supConstruccion: '',
})
/** Campo de la captura manual: sin etiqueta visible, con aria-label propio */
function EntradaFila({
etiqueta,
valor,
onChange,
tipo = 'text',
ancho = '',
}: {
etiqueta: string
valor: string
onChange: (valor: string) => void
tipo?: 'text' | 'number'
ancho?: string
}) {
return (
<input
type={tipo}
value={valor}
aria-label={etiqueta}
placeholder={etiqueta}
onChange={(e) => onChange(e.target.value)}
className={`w-full rounded-lg border border-[#D1D5DB] bg-white px-2 py-1.5 text-xs text-[#111827] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] ${ancho}`}
/>
)
}
const UNIDADES_POR_PAGINA = 10
/** Inventario del desarrollo: alta por generador o manual, y tabla existente */
export function TabInventario({ desarrollo, onRefrescar }: Props) {
const [modo, setModo] = useState<Modo>('generador')
// Generador
const [patron, setPatron] = useState('')
const [desde, setDesde] = useState('')
const [hasta, setHasta] = useState('')
const [modeloId, setModeloId] = useState('')
const [precioLista, setPrecioLista] = useState('')
const [precioAcabados, setPrecioAcabados] = useState('')
const [supConstruccion, setSupConstruccion] = useState('')
// Manual
const [filas, setFilas] = useState<FilaManual[]>([filaVacia(), filaVacia()])
const [error, setError] = useState('')
const [exito, setExito] = useState('')
const [enviando, setEnviando] = useState(false)
// Inventario existente (GET /api/unidades?desarrollo=CLAVE)
const [unidades, setUnidades] = useState<UnidadDesarrollo[]>([])
const [cargandoUnidades, setCargandoUnidades] = useState(true)
const [avisoUnidades, setAvisoUnidades] = useState('')
const [busqueda, setBusqueda] = useState('')
const [filtroEstatus, setFiltroEstatus] = useState('')
const [pagina, setPagina] = useState(1)
const clave = desarrollo.clave
const cargarUnidades = useCallback(async () => {
setCargandoUnidades(true)
try {
setUnidades(await listarUnidades(clave))
setAvisoUnidades('')
} catch (err) {
setUnidades([])
setAvisoUnidades(
err instanceof Error && err.message
? `No se pudo cargar el inventario: ${err.message}`
: 'No se pudo cargar el inventario existente.',
)
} finally {
setCargandoUnidades(false)
}
}, [clave])
useEffect(() => {
void cargarUnidades()
}, [cargarUnidades])
const num = (valor: string) => (valor.trim() === '' ? Number.NaN : Number(valor))
const desdeNum = num(desde)
const hastaNum = num(hasta)
const errorGenerador = validarGenerador(patron, desdeNum, hastaNum)
const totalPrevisto = totalDelRango(desdeNum, hastaNum)
const previos = useMemo(
() => generarCodigos(patron, clave, desdeNum, hastaNum, 5),
[patron, clave, desdeNum, hastaNum],
)
const modelos = desarrollo.modelos ?? []
const guardarGenerador = async () => {
setError('')
setExito('')
if (errorGenerador) {
setError(errorGenerador)
return
}
const lista = aNumero(precioLista)
if (lista === null) {
setError('Escribe el precio de lista de las unidades.')
return
}
setEnviando(true)
try {
const resultado = await crearUnidades(desarrollo.id, {
generar: {
patron: patron.trim(),
desde: desdeNum,
hasta: hastaNum,
...(modeloId ? { modeloId } : {}),
precioLista: lista,
...(aNumero(precioAcabados) !== null ? { precioAcabados: aNumero(precioAcabados)! } : {}),
...(aNumero(supConstruccion) !== null
? { supConstruccion: aNumero(supConstruccion)! }
: {}),
},
})
setExito(`Se crearon ${resultado.creadas} unidades en el inventario.`)
setPatron('')
setDesde('')
setHasta('')
await cargarUnidades()
await onRefrescar()
} catch (err) {
setError(err instanceof Error && err.message ? err.message : 'No se pudieron crear las unidades.')
} finally {
setEnviando(false)
}
}
const guardarManual = async () => {
setError('')
setExito('')
const capturadas = filas.filter(
(fila) =>
fila.codigo.trim() !== '' ||
fila.manzana.trim() !== '' ||
fila.lote.trim() !== '' ||
fila.nivel.trim() !== '' ||
fila.precioLista.trim() !== '' ||
fila.precioAcabados.trim() !== '' ||
fila.supConstruccion.trim() !== '',
)
if (capturadas.length === 0) {
setError('Captura al menos una unidad con su código y precio de lista.')
return
}
if (capturadas.some((fila) => fila.codigo.trim() === '')) {
setError('Cada unidad necesita un código.')
return
}
const sinPrecio = capturadas.filter((fila) => aNumero(fila.precioLista) === null)
if (sinPrecio.length > 0) {
setError(
`El precio de lista es obligatorio: falta en ${sinPrecio
.map((fila) => fila.codigo.trim())
.join(', ')}.`,
)
return
}
const codigos = capturadas.map((fila) => fila.codigo.trim())
const repetidos = codigos.filter((codigo, i) => codigos.indexOf(codigo) !== i)
if (repetidos.length > 0) {
setError(`Hay códigos repetidos en la captura: ${[...new Set(repetidos)].join(', ')}.`)
return
}
const unidadesNuevas: UnidadManual[] = capturadas.map((fila) => {
const precioAcabados = aNumero(fila.precioAcabados)
const supConstruccion = aNumero(fila.supConstruccion)
return {
codigo: fila.codigo.trim(),
...(fila.manzana.trim() !== '' ? { manzana: fila.manzana.trim() } : {}),
...(fila.lote.trim() !== '' ? { lote: fila.lote.trim() } : {}),
...(fila.nivel.trim() !== '' ? { nivel: fila.nivel.trim() } : {}),
...(fila.modeloId !== '' ? { modeloId: fila.modeloId } : {}),
precioLista: aNumero(fila.precioLista)!,
...(precioAcabados !== null ? { precioAcabados } : {}),
...(supConstruccion !== null ? { supConstruccion } : {}),
}
})
setEnviando(true)
try {
const resultado = await crearUnidades(desarrollo.id, { unidades: unidadesNuevas })
setExito(`Se crearon ${resultado.creadas} unidades en el inventario.`)
setFilas([filaVacia(), filaVacia()])
await cargarUnidades()
await onRefrescar()
} catch (err) {
setError(err instanceof Error && err.message ? err.message : 'No se pudieron crear las unidades.')
} finally {
setEnviando(false)
}
}
const borrarUnidad = async (unidad: UnidadDesarrollo) => {
if (
typeof window !== 'undefined' &&
!window.confirm(`¿Eliminar la unidad ${unidad.codigo} del inventario?`)
) {
return
}
setError('')
setExito('')
try {
await eliminarUnidad(desarrollo.id, unidad.codigo)
setExito(`Unidad ${unidad.codigo} eliminada.`)
await cargarUnidades()
await onRefrescar()
} catch (err) {
setError(
err instanceof Error && err.message
? err.message
: `No se pudo eliminar la unidad ${unidad.codigo}.`,
)
}
}
// Conteos por estatus: los del servidor mandan; si no vienen, se cuentan aquí
const conteos = useMemo(() => {
const delServidor = desarrollo.conteosPorEstatus ?? {}
if (Object.keys(delServidor).length > 0) return delServidor
return unidades.reduce<Record<string, number>>((acc, unidad) => {
const estatus = estatusDe(unidad) ?? 'SIN ESTATUS'
acc[estatus] = (acc[estatus] ?? 0) + 1
return acc
}, {})
}, [desarrollo.conteosPorEstatus, unidades])
const termino = busqueda.trim().toLowerCase()
const filtradas = unidades.filter((unidad) => {
const coincideTexto =
termino === '' ||
unidad.codigo.toLowerCase().includes(termino) ||
(unidad.modelo ?? '').toLowerCase().includes(termino)
const coincideEstatus = filtroEstatus === '' || (estatusDe(unidad) ?? '') === filtroEstatus
return coincideTexto && coincideEstatus
})
const totalPaginas = Math.max(1, Math.ceil(filtradas.length / UNIDADES_POR_PAGINA))
const paginaActual = Math.min(pagina, totalPaginas)
const visibles = filtradas.slice(
(paginaActual - 1) * UNIDADES_POR_PAGINA,
paginaActual * UNIDADES_POR_PAGINA,
)
return (
<div className="space-y-4">
<section className={`${TARJETA} p-4`}>
<h2 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
Agregar inventario
</h2>
<div className="mt-3 flex gap-1 rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] p-1 dark:border-[#1E293B] dark:bg-[#0F172A]">
{(
[
['generador', 'Generador'],
['manual', 'Captura manual'],
] as const
).map(([clave, etiqueta]) => (
<button
key={clave}
type="button"
onClick={() => {
setModo(clave)
setError('')
setExito('')
}}
aria-pressed={modo === clave}
className={`rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
modo === clave
? 'bg-white text-[#111827] shadow-sm dark:bg-[#1E293B] dark:text-[#E2E8F0]'
: 'text-[#64748B] hover:text-[#374151] dark:text-[#94A3B8] dark:hover:text-[#CBD5E1]'
}`}
>
{etiqueta}
</button>
))}
</div>
{modo === 'generador' ? (
<div className="mt-4 space-y-4">
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Campo
id="inv-patron"
etiqueta="Patrón de código"
requerido
ayuda="Admite {clave} y {n}. Ejemplos: SENDA-401-{n}, {clave}-{n}, CASA-{n}."
className="sm:col-span-2"
>
<input
id="inv-patron"
type="text"
value={patron}
onChange={(e) => setPatron(e.target.value)}
placeholder="SENDA-401-{n}"
className={INPUT}
/>
</Campo>
<Campo id="inv-desde" etiqueta="Desde" requerido>
<input
id="inv-desde"
type="number"
value={desde}
onChange={(e) => setDesde(e.target.value)}
placeholder="1"
className={INPUT}
/>
</Campo>
<Campo id="inv-hasta" etiqueta="Hasta" requerido>
<input
id="inv-hasta"
type="number"
value={hasta}
onChange={(e) => setHasta(e.target.value)}
placeholder="20"
className={INPUT}
/>
</Campo>
<Campo id="inv-modelo" etiqueta="Modelo (opcional)">
<select
id="inv-modelo"
value={modeloId}
onChange={(e) => setModeloId(e.target.value)}
className={INPUT}
>
<option value="">Sin modelo</option>
{modelos.map((modelo) => (
<option key={modelo.id} value={modelo.id}>
{modelo.nombre}
</option>
))}
</select>
</Campo>
<Campo id="inv-precio-lista" etiqueta="Precio de lista" requerido>
<input
id="inv-precio-lista"
type="number"
min={0}
value={precioLista}
onChange={(e) => setPrecioLista(e.target.value)}
placeholder="1500000"
className={INPUT}
/>
</Campo>
<Campo id="inv-precio-acabados" etiqueta="Precio con acabados">
<input
id="inv-precio-acabados"
type="number"
min={0}
value={precioAcabados}
onChange={(e) => setPrecioAcabados(e.target.value)}
placeholder="1650000"
className={INPUT}
/>
</Campo>
<Campo id="inv-sup-construccion" etiqueta="Superficie de construcción (m²)">
<input
id="inv-sup-construccion"
type="number"
min={0}
step="any"
value={supConstruccion}
onChange={(e) => setSupConstruccion(e.target.value)}
placeholder="120"
className={INPUT}
/>
</Campo>
</div>
{/* Vista previa en vivo */}
<div
className="rounded-xl border border-[#E5E7EB] bg-[#F9FAFB] p-4 dark:border-[#334155] dark:bg-[#0F172A]"
aria-live="polite"
>
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
Vista previa
</p>
{errorGenerador ? (
<p className="mt-2 text-sm text-[#64748B] dark:text-[#94A3B8]">
Completa patrón y rango para ver los códigos que se crearán.
</p>
) : (
<>
<p className="mt-2 text-sm text-[#111827] dark:text-[#E2E8F0]">
{`Se crearán ${totalPrevisto} unidades.`}
</p>
<ol className="mt-2 flex flex-wrap gap-1.5">
{previos.map((codigo) => (
<li
key={codigo}
className="rounded-lg border border-[#E5E7EB] bg-white px-2 py-1 font-mono text-xs text-[#111827] dark:border-[#334155] dark:bg-[#1E293B] dark:text-[#E2E8F0]"
>
{codigo}
</li>
))}
{totalPrevisto > previos.length && (
<li className="px-2 py-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
{`… y ${totalPrevisto - previos.length} más`}
</li>
)}
</ol>
</>
)}
</div>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<button
type="button"
onClick={() => void guardarGenerador()}
disabled={enviando || errorGenerador !== null || precioLista.trim() === ''}
className={BOTON_PRIMARIO}
>
{enviando ? 'Creando…' : `Crear ${totalPrevisto || ''} unidades`.trim()}
</button>
{errorGenerador && !error && (
<p className={AYUDA}>{errorGenerador}</p>
)}
{!errorGenerador && precioLista.trim() === '' && (
<p className={AYUDA}>Falta el precio de lista de las unidades.</p>
)}
</div>
</div>
) : (
<div className="mt-4 space-y-3">
<div className="hidden gap-2 xl:grid xl:grid-cols-[1.2fr_0.8fr_0.8fr_0.8fr_1fr_1fr_1fr_0.8fr_auto]">
{['Código', 'Manzana', 'Lote', 'Nivel', 'Modelo', 'Precio lista *', 'Con acabados', 'm² const.', ''].map(
(titulo, i) => (
<span
key={`${titulo}-${i}`}
className="text-[11px] font-medium uppercase tracking-wider text-[#94A3B8] dark:text-[#64748B]"
>
{titulo}
</span>
),
)}
</div>
{filas.map((fila, indice) => (
<div
key={indice}
className="grid gap-2 rounded-lg border border-[#E5E7EB] p-2 xl:grid-cols-[1.2fr_0.8fr_0.8fr_0.8fr_1fr_1fr_1fr_0.8fr_auto] xl:items-center xl:border-0 xl:p-0 dark:border-[#334155]"
>
<EntradaFila
etiqueta={`Código de la fila ${indice + 1}`}
valor={fila.codigo}
onChange={(valor) =>
setFilas((prev) => prev.map((f, i) => (i === indice ? { ...f, codigo: valor } : f)))
}
/>
<EntradaFila
etiqueta={`Manzana de la fila ${indice + 1}`}
valor={fila.manzana}
onChange={(valor) =>
setFilas((prev) => prev.map((f, i) => (i === indice ? { ...f, manzana: valor } : f)))
}
/>
<EntradaFila
etiqueta={`Lote de la fila ${indice + 1}`}
valor={fila.lote}
onChange={(valor) =>
setFilas((prev) => prev.map((f, i) => (i === indice ? { ...f, lote: valor } : f)))
}
/>
<EntradaFila
etiqueta={`Nivel de la fila ${indice + 1}`}
valor={fila.nivel}
onChange={(valor) =>
setFilas((prev) => prev.map((f, i) => (i === indice ? { ...f, nivel: valor } : f)))
}
/>
<select
value={fila.modeloId}
aria-label={`Modelo de la fila ${indice + 1}`}
onChange={(e) =>
setFilas((prev) =>
prev.map((f, i) => (i === indice ? { ...f, modeloId: e.target.value } : f)),
)
}
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-2 py-1.5 text-xs text-[#111827] focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]"
>
<option value="">Modelo…</option>
{modelos.map((modelo) => (
<option key={modelo.id} value={modelo.id}>
{modelo.nombre}
</option>
))}
</select>
<EntradaFila
etiqueta={`Precio de lista de la fila ${indice + 1}`}
valor={fila.precioLista}
tipo="number"
onChange={(valor) =>
setFilas((prev) =>
prev.map((f, i) => (i === indice ? { ...f, precioLista: valor } : f)),
)
}
/>
<EntradaFila
etiqueta={`Precio con acabados de la fila ${indice + 1}`}
valor={fila.precioAcabados}
tipo="number"
onChange={(valor) =>
setFilas((prev) =>
prev.map((f, i) => (i === indice ? { ...f, precioAcabados: valor } : f)),
)
}
/>
<EntradaFila
etiqueta={`Superficie de construcción de la fila ${indice + 1}`}
valor={fila.supConstruccion}
tipo="number"
onChange={(valor) =>
setFilas((prev) =>
prev.map((f, i) => (i === indice ? { ...f, supConstruccion: valor } : f)),
)
}
/>
<button
type="button"
onClick={() => setFilas((prev) => prev.filter((_, i) => i !== indice))}
disabled={filas.length === 1}
aria-label={`Quitar fila ${indice + 1}`}
className={BOTON_MINIMO}
>
Quitar
</button>
</div>
))}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<button
type="button"
onClick={() => setFilas((prev) => [...prev, filaVacia()])}
className={BOTON_SECUNDARIO}
>
+ Agregar fila
</button>
<button
type="button"
onClick={() => void guardarManual()}
disabled={enviando}
className={BOTON_PRIMARIO}
>
{enviando ? 'Guardando…' : 'Guardar unidades'}
</button>
</div>
</div>
)}
{error && <div className="mt-3"><MensajeError>{error}</MensajeError></div>}
{exito && <div className="mt-3"><MensajeExito>{exito}</MensajeExito></div>}
</section>
<section className={`${TARJETA} p-4`}>
<h2 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
Inventario existente
</h2>
{Object.keys(conteos).length > 0 && (
<div className="mt-3 flex flex-wrap items-center gap-2">
{Object.entries(conteos).map(([estatus, cantidad]) => (
<span key={estatus} className="flex items-center gap-1.5">
<BadgeEstatus estatus={estatus} />
<span className="text-xs font-medium text-[#475569] dark:text-[#CBD5E1]">{cantidad}</span>
</span>
))}
</div>
)}
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<div>
<label htmlFor="inv-buscar" className="block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
Buscar unidad
</label>
<input
id="inv-buscar"
type="search"
value={busqueda}
onChange={(e) => {
setBusqueda(e.target.value)
setPagina(1)
}}
placeholder="Código o modelo…"
className={INPUT}
/>
</div>
<div>
<label htmlFor="inv-estatus" className="block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
Estatus
</label>
<select
id="inv-estatus"
value={filtroEstatus}
onChange={(e) => {
setFiltroEstatus(e.target.value)
setPagina(1)
}}
className={INPUT}
>
<option value="">Todos los estatus</option>
{ESTATUS_UNIDAD.map((estatus) => (
<option key={estatus} value={estatus}>
{estatus}
</option>
))}
</select>
</div>
</div>
{avisoUnidades && <div className="mt-3"><MensajeError>{avisoUnidades}</MensajeError></div>}
<div className="mt-4">
{cargandoUnidades ? (
<div className="space-y-3 py-2">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-2/3" />
</div>
))}
</div>
) : visibles.length === 0 ? (
<p className="py-6 text-center text-sm text-[#64748B] dark:text-[#94A3B8]">
{unidades.length === 0
? 'Aún no hay unidades en este desarrollo.'
: 'Ninguna unidad coincide con el filtro.'}
</p>
) : (
<ul className="divide-y divide-[#E5E7EB] dark:divide-[#334155]">
{visibles.map((unidad) => (
<li
key={unidad.codigo}
className="flex flex-col gap-2 py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div className="min-w-0">
<p className="font-mono text-sm text-[#111827] dark:text-[#E2E8F0]">
{unidad.codigo}
</p>
<p className="mt-0.5 text-xs text-[#64748B] dark:text-[#94A3B8]">
{[
unidad.modelo,
unidad.nivel ? `Nivel ${unidad.nivel}` : null,
unidad.supConstruccion != null ? `${unidad.supConstruccion} m²` : null,
unidad.fechaEntrega ? `Entrega ${unidad.fechaEntrega}` : null,
`${moneda(unidad.precioSinAcabados ?? unidad.precioLista)} lista`,
`${moneda(unidad.precioConAcabados)} c/acabados`,
]
.filter(Boolean)
.join(' · ')}
</p>
</div>
<div className="flex shrink-0 items-center gap-2">
<BadgeEstatus estatus={estatusDe(unidad)} />
<button
type="button"
onClick={() => void borrarUnidad(unidad)}
aria-label={`Eliminar unidad ${unidad.codigo}`}
className={BOTON_PELIGRO}
>
Eliminar
</button>
</div>
</li>
))}
</ul>
)}
</div>
{filtradas.length > UNIDADES_POR_PAGINA && (
<div className="mt-4 flex flex-col items-center gap-2 sm:flex-row sm:justify-between">
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
Mostrando {(paginaActual - 1) * UNIDADES_POR_PAGINA + 1}–
{Math.min(paginaActual * UNIDADES_POR_PAGINA, filtradas.length)} de {filtradas.length} unidades
</p>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setPagina(paginaActual - 1)}
disabled={paginaActual === 1}
className={BOTON_SECUNDARIO}
>
Anterior
</button>
<span className="text-sm text-[#64748B] dark:text-[#94A3B8]">
Página {paginaActual} de {totalPaginas}
</span>
<button
type="button"
onClick={() => setPagina(paginaActual + 1)}
disabled={paginaActual === totalPaginas}
className={BOTON_SECUNDARIO}
>
Siguiente
</button>
</div>
</div>
)}
</section>
</div>
)
}

View File

@@ -0,0 +1,360 @@
import { useEffect, useState, type FormEvent } from 'react'
import {
actualizarModelo,
crearModelo,
eliminarModelo,
listarUnidades,
type DesarrolloFicha,
type ModeloDesarrollo,
} from '../../services/desarrollos'
import {
aNumero,
aTexto,
BOTON_MINIMO,
BOTON_PELIGRO,
BOTON_PRIMARIO,
BOTON_SECUNDARIO,
Campo,
INPUT,
INPUT_ERROR,
MensajeError,
MensajeExito,
TARJETA,
} from './comun'
interface Props {
desarrollo: DesarrolloFicha
onRefrescar: () => Promise<void> | void
}
interface FormModeloEstado {
nombre: string
recamaras: string
banos: string
estacionamientos: string
supConstruccion: string
activo: boolean
}
const estadoVacio: FormModeloEstado = {
nombre: '',
recamaras: '',
banos: '',
estacionamientos: '',
supConstruccion: '',
activo: true,
}
function estadoDesdeModelo(modelo: ModeloDesarrollo): FormModeloEstado {
return {
nombre: modelo.nombre,
recamaras: aTexto(modelo.recamaras),
banos: aTexto(modelo.banos),
estacionamientos: aTexto(modelo.estacionamientos),
supConstruccion: aTexto(modelo.supConstruccion),
activo: modelo.activo,
}
}
/** Alta y baja de modelos de un desarrollo */
export function TabModelos({ desarrollo, onRefrescar }: Props) {
const [nuevo, setNuevo] = useState<FormModeloEstado>(estadoVacio)
const [formAbierto, setFormAbierto] = useState(false)
const [editandoId, setEditandoId] = useState<string | null>(null)
const [edicion, setEdicion] = useState<FormModeloEstado>(estadoVacio)
const [error, setError] = useState('')
const [exito, setExito] = useState('')
const [guardando, setGuardando] = useState(false)
const modelos = desarrollo.modelos ?? []
// Unidades por modelo: se leen del inventario para poder avisar antes de borrar
const [conteoPorModelo, setConteoPorModelo] = useState<Record<string, number>>({})
useEffect(() => {
let cancelado = false
listarUnidades(desarrollo.clave)
.then((unidades) => {
if (cancelado) return
const conteo: Record<string, number> = {}
for (const unidad of unidades) {
if (!unidad.modelo) continue
conteo[unidad.modelo] = (conteo[unidad.modelo] ?? 0) + 1
}
setConteoPorModelo(conteo)
})
.catch(() => {
// Sin inventario disponible el borrado sigue avisando con el 409 del API
})
return () => {
cancelado = true
}
}, [desarrollo.clave])
const unidadesDeModelo = (nombre: string) => conteoPorModelo[nombre] ?? 0
const crear = async (evento: FormEvent) => {
evento.preventDefault()
setError('')
setExito('')
if (nuevo.nombre.trim().length < 1) {
setError('Escribe el nombre del modelo.')
return
}
setGuardando(true)
try {
await crearModelo(desarrollo.id, {
nombre: nuevo.nombre.trim(),
recamaras: aNumero(nuevo.recamaras),
banos: aNumero(nuevo.banos),
estacionamientos: aNumero(nuevo.estacionamientos),
supConstruccion: aNumero(nuevo.supConstruccion),
activo: nuevo.activo,
})
setNuevo(estadoVacio)
setFormAbierto(false)
setExito(`Modelo "${nuevo.nombre.trim()}" agregado.`)
await onRefrescar()
} catch (err) {
setError(err instanceof Error && err.message ? err.message : 'No se pudo crear el modelo.')
} finally {
setGuardando(false)
}
}
const guardarEdicion = async (evento: FormEvent, modeloId: string) => {
evento.preventDefault()
setError('')
setExito('')
if (edicion.nombre.trim().length < 1) {
setError('Escribe el nombre del modelo.')
return
}
setGuardando(true)
try {
await actualizarModelo(modeloId, {
nombre: edicion.nombre.trim(),
recamaras: aNumero(edicion.recamaras),
banos: aNumero(edicion.banos),
estacionamientos: aNumero(edicion.estacionamientos),
supConstruccion: aNumero(edicion.supConstruccion),
activo: edicion.activo,
})
setEditandoId(null)
setExito('Modelo actualizado.')
await onRefrescar()
} catch (err) {
setError(err instanceof Error && err.message ? err.message : 'No se pudo actualizar el modelo.')
} finally {
setGuardando(false)
}
}
const borrar = async (modelo: ModeloDesarrollo) => {
const conUnidades = unidadesDeModelo(modelo.nombre)
const aviso =
conUnidades > 0
? `El modelo "${modelo.nombre}" tiene ${conUnidades} unidades registradas. ¿Eliminarlo de todos modos?`
: `¿Eliminar el modelo "${modelo.nombre}"?`
if (typeof window !== 'undefined' && !window.confirm(aviso)) return
setError('')
setExito('')
try {
await eliminarModelo(modelo.id)
setExito(`Modelo "${modelo.nombre}" eliminado.`)
await onRefrescar()
} catch (err) {
setError(err instanceof Error && err.message ? err.message : 'No se pudo eliminar el modelo.')
}
}
const campos = (
valores: FormModeloEstado,
setValores: (fn: (prev: FormModeloEstado) => FormModeloEstado) => void,
prefijo: string,
) => (
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<Campo id={`${prefijo}-nombre`} etiqueta="Nombre" requerido>
<input
id={`${prefijo}-nombre`}
type="text"
value={valores.nombre}
onChange={(e) => setValores((prev) => ({ ...prev, nombre: e.target.value }))}
placeholder="2RA"
className={`${INPUT} ${valores.nombre.trim() === '' ? INPUT_ERROR : ''}`}
/>
</Campo>
{(
[
['recamaras', 'Recámaras'],
['banos', 'Baños'],
['estacionamientos', 'Estacionamientos'],
['supConstruccion', 'Superficie (m²)'],
] as const
).map(([campo, etiqueta]) => (
<Campo key={campo} id={`${prefijo}-${campo}`} etiqueta={etiqueta}>
<input
id={`${prefijo}-${campo}`}
type="number"
min={0}
step="any"
value={valores[campo]}
onChange={(e) => setValores((prev) => ({ ...prev, [campo]: e.target.value }))}
className={INPUT}
/>
</Campo>
))}
<Campo id={`${prefijo}-activo`} etiqueta="Estado del modelo">
<select
id={`${prefijo}-activo`}
value={valores.activo ? 'activo' : 'inactivo'}
onChange={(e) => setValores((prev) => ({ ...prev, activo: e.target.value === 'activo' }))}
className={INPUT}
>
<option value="activo">Activo</option>
<option value="inactivo">Inactivo</option>
</select>
</Campo>
</div>
)
return (
<div className="space-y-4">
<section className={`${TARJETA} p-4`}>
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Modelos</h2>
<button
type="button"
onClick={() => {
setFormAbierto((v) => !v)
setError('')
setExito('')
}}
className={BOTON_MINIMO}
>
{formAbierto ? 'Cerrar' : '+ Nuevo modelo'}
</button>
</div>
{formAbierto && (
<form onSubmit={crear} className="mt-4 space-y-3">
{campos(nuevo, setNuevo, 'modelo-nuevo')}
<div className="flex items-center gap-3">
<button type="submit" disabled={guardando} className={BOTON_PRIMARIO}>
{guardando ? 'Guardando…' : 'Agregar modelo'}
</button>
<button
type="button"
onClick={() => {
setFormAbierto(false)
setNuevo(estadoVacio)
setError('')
}}
className={BOTON_SECUNDARIO}
>
Cancelar
</button>
</div>
</form>
)}
{error && <div className="mt-3"><MensajeError>{error}</MensajeError></div>}
{exito && <div className="mt-3"><MensajeExito>{exito}</MensajeExito></div>}
</section>
<section className={`${TARJETA} overflow-hidden`}>
{modelos.length === 0 ? (
<p className="p-6 text-center text-sm text-[#64748B] dark:text-[#94A3B8]">
Este desarrollo aún no tiene modelos.
</p>
) : (
<ul className="divide-y divide-[#E5E7EB] dark:divide-[#334155]">
{modelos.map((modelo) => {
const conUnidades = unidadesDeModelo(modelo.nombre)
return (
<li key={modelo.id} className="p-4">
{editandoId === modelo.id ? (
<form
onSubmit={(e) => void guardarEdicion(e, modelo.id)}
className="space-y-3"
aria-label={`Editar modelo ${modelo.nombre}`}
>
{campos(edicion, setEdicion, `modelo-${modelo.id}`)}
<div className="flex items-center gap-3">
<button type="submit" disabled={guardando} className={BOTON_PRIMARIO}>
{guardando ? 'Guardando…' : 'Guardar'}
</button>
<button
type="button"
onClick={() => setEditandoId(null)}
className={BOTON_SECUNDARIO}
>
Cancelar
</button>
</div>
</form>
) : (
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">
{modelo.nombre}
{!modelo.activo && (
<span className="ml-2 rounded-full bg-[#F1F5F9] px-2 py-0.5 text-[11px] font-medium text-[#475569] dark:bg-[#334155] dark:text-[#CBD5E1]">
Inactivo
</span>
)}
</p>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
{[
modelo.recamaras != null ? `${modelo.recamaras} rec.` : null,
modelo.banos != null ? `${modelo.banos} baños` : null,
modelo.estacionamientos != null
? `${modelo.estacionamientos} estac.`
: null,
modelo.supConstruccion != null
? `${modelo.supConstruccion} m²`
: null,
]
.filter(Boolean)
.join(' · ') || 'Sin medidas capturadas'}
</p>
{conUnidades > 0 && (
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
Tiene {conUnidades} unidades en el inventario.
</p>
)}
</div>
<div className="flex shrink-0 gap-2">
<button
type="button"
onClick={() => {
setEditandoId(modelo.id)
setEdicion(estadoDesdeModelo(modelo))
setError('')
setExito('')
}}
aria-label={`Editar modelo ${modelo.nombre}`}
className={BOTON_MINIMO}
>
Editar
</button>
<button
type="button"
onClick={() => void borrar(modelo)}
aria-label={`Eliminar modelo ${modelo.nombre}`}
className={BOTON_PELIGRO}
>
Eliminar
</button>
</div>
</div>
)}
</li>
)
})}
</ul>
)}
</section>
</div>
)
}

View File

@@ -0,0 +1,558 @@
import { useEffect, useState } from 'react'
import {
guardarReglas,
obtenerReglasEfectivas,
type BasePrecio,
type DesarrolloFicha,
type FuenteDeN,
type ModoMensualidades,
type PropuestaReglas,
type ReglasCotizador,
} from '../../services/desarrollos'
import {
BOTON_PRIMARIO,
Campo,
ETIQUETA_FUENTE_N,
INPUT,
MensajeError,
MensajeExito,
TARJETA,
} from './comun'
import {
completarReglas,
fraccionDePct,
formatearPct,
PROPUESTAS_BASE,
propuestasEditables,
propuestasHabilitadas,
pctDeFraccion,
resumenPropuesta,
} from './reglas'
interface Props {
desarrollo: DesarrolloFicha
onRefrescar: () => Promise<void> | void
}
/** Propuesta con los porcentajes en texto para poder escribirlos en % */
interface PropuestaEditable {
tipo: string
claveDescuento: string
titulo: string
descuentoDefault: string
pctEnganche: string
engancheBase: BasePrecio
pctPagoFinal: string
pagoFinalBase: BasePrecio
modoMensualidades: ModoMensualidades
engancheCeroSinApartado: boolean
}
interface GeneralesEditable {
precioUnico: boolean
fuenteDeN: FuenteDeN
nFijo: string
nDefault: string
cuotaMantenimiento: string
prefijoUnidadPdf: string
}
const MODOS: ModoMensualidades[] = ['remanente', 'remanenteLista', 'porcentaje', 'ninguna']
const ETIQUETA_MODO: Record<ModoMensualidades, string> = {
remanente: 'Remanente (precio final − apartado − enganche − pago final)',
remanenteLista: 'Remanente sobre precio de lista',
porcentaje: 'Porcentaje del precio final',
ninguna: 'Sin mensualidades',
}
const ETIQUETA_BASE_OPCION: Record<BasePrecio, string> = {
lista: 'Precio de lista',
final: 'Precio final',
}
const aPropuestaEditable = (p: PropuestaReglas): PropuestaEditable => ({
tipo: p.tipo,
claveDescuento: p.claveDescuento,
titulo: p.titulo,
descuentoDefault: formatearPct(pctDeFraccion(p.descuentoDefault)),
pctEnganche: formatearPct(pctDeFraccion(p.pctEnganche)),
engancheBase: p.engancheBase,
pctPagoFinal: formatearPct(pctDeFraccion(p.pctPagoFinal)),
pagoFinalBase: p.pagoFinalBase,
modoMensualidades: p.modoMensualidades,
engancheCeroSinApartado: p.engancheCeroSinApartado === true,
})
const comoFraccion = (valor: string) => fraccionDePct(Number(valor.trim() === '' ? 0 : valor))
/** Resumen legible usando el mismo formateador del guardado */
const resumenDe = (p: PropuestaEditable) =>
resumenPropuesta({
tipo: p.tipo,
claveDescuento: p.claveDescuento,
titulo: p.titulo,
descuentoDefault: comoFraccion(p.descuentoDefault),
pctEnganche: comoFraccion(p.pctEnganche),
engancheBase: p.engancheBase,
pctPagoFinal: comoFraccion(p.pctPagoFinal),
pagoFinalBase: p.pagoFinalBase,
modoMensualidades: p.modoMensualidades,
})
function generalesDe(reglas: ReglasCotizador): GeneralesEditable {
return {
precioUnico: reglas.precioUnico,
fuenteDeN: reglas.fuenteDeN,
nFijo: reglas.nFijo != null ? String(reglas.nFijo) : '',
nDefault: String(reglas.nDefault ?? 0),
cuotaMantenimiento: String(reglas.cuotaMantenimiento ?? 0),
prefijoUnidadPdf: reglas.prefijoUnidadPdf ?? '',
}
}
function propuestasDe(reglas: ReglasCotizador | null): Record<string, PropuestaEditable> {
const base = propuestasEditables(reglas)
const editables: Record<string, PropuestaEditable> = {}
for (const [tipo, propuesta] of Object.entries(base)) {
editables[tipo] = aPropuestaEditable(propuesta)
}
return editables
}
/** Editor de las reglas del cotizador del desarrollo */
export function TabReglas({ desarrollo, onRefrescar }: Props) {
// Un desarrollo sin reglas guardadas arranca con las reglas VIGENTES del
// cotizador (GET /desarrollos/reglas-cotizador) para no recapturar a mano.
const [reglasVigentes, setReglasVigentes] = useState<ReglasCotizador | null>(null)
const [cargandoVigentes, setCargandoVigentes] = useState(desarrollo.reglas === null)
const reglasBase = desarrollo.reglas ?? reglasVigentes
const [generales, setGenerales] = useState<GeneralesEditable>(() =>
generalesDe(completarReglas(desarrollo.reglas)),
)
const [habilitadas, setHabilitadas] = useState<Record<string, boolean>>(() =>
propuestasHabilitadas(desarrollo.reglas),
)
const [propuestas, setPropuestas] = useState<Record<string, PropuestaEditable>>(() =>
propuestasDe(desarrollo.reglas),
)
const [error, setError] = useState('')
const [exito, setExito] = useState('')
const [guardando, setGuardando] = useState(false)
useEffect(() => {
if (desarrollo.reglas !== null) {
setReglasVigentes(null)
setCargandoVigentes(false)
return
}
let cancelado = false
setCargandoVigentes(true)
obtenerReglasEfectivas()
.then((mapa) => {
if (!cancelado) setReglasVigentes(mapa[desarrollo.clave] ?? null)
})
.catch(() => {
// Sin reglas vigentes se cae a los valores por defecto del editor
})
.finally(() => {
if (!cancelado) setCargandoVigentes(false)
})
return () => {
cancelado = true
}
}, [desarrollo.clave, desarrollo.reglas])
// Si la ficha se recarga (p. ej. tras guardar), se refresca el editor
useEffect(() => {
setGenerales(generalesDe(completarReglas(reglasBase)))
setHabilitadas(propuestasHabilitadas(reglasBase))
setPropuestas(propuestasDe(reglasBase))
}, [reglasBase])
const setPropuesta = (tipo: string, cambios: Partial<PropuestaEditable>) =>
setPropuestas((prev) => ({ ...prev, [tipo]: { ...prev[tipo]!, ...cambios } }))
const activas = PROPUESTAS_BASE.filter((base) => habilitadas[base.tipo])
if (cargandoVigentes && desarrollo.reglas === null) {
return (
<div className={`${TARJETA} p-6`}>
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">Cargando reglas del cotizador…</p>
</div>
)
}
const guardar = async () => {
setError('')
setExito('')
if (activas.length === 0) {
setError('Activa al menos un esquema de cotización.')
return
}
if (generales.prefijoUnidadPdf.trim() === '') {
setError('Escribe el prefijo de unidad que se imprime en el PDF (CASA, DEPTO…).')
return
}
for (const base of activas) {
const p = propuestas[base.tipo]!
const suma = Number(p.pctEnganche || 0) + Number(p.pctPagoFinal || 0)
if (!Number.isFinite(suma) || suma > 100) {
setError(
`En "${p.titulo}" el enganche más el pago final no pueden pasar del 100% (hoy suma ${suma}%).`,
)
return
}
if (Number(p.pctEnganche || 0) < 0 || Number(p.pctPagoFinal || 0) < 0) {
setError(`En "${p.titulo}" los porcentajes no pueden ser negativos.`)
return
}
}
const nFijo = Number(generales.nFijo)
const reglas: ReglasCotizador = {
precioUnico: generales.precioUnico,
fuenteDeN: generales.fuenteDeN,
...(generales.fuenteDeN === 'fijo' && generales.nFijo.trim() !== '' && Number.isFinite(nFijo)
? { nFijo: Math.max(0, Math.round(nFijo)) }
: {}),
nDefault: Math.max(0, Math.round(Number(generales.nDefault || 0))),
cuotaMantenimiento: Math.max(0, Number(generales.cuotaMantenimiento || 0)),
prefijoUnidadPdf: generales.prefijoUnidadPdf.trim(),
propuestas: activas.map((base) => {
const p = propuestas[base.tipo]!
return {
tipo: p.tipo,
claveDescuento: p.claveDescuento,
titulo: p.titulo.trim() || base.titulo,
descuentoDefault: comoFraccion(p.descuentoDefault),
pctEnganche: comoFraccion(p.pctEnganche),
engancheBase: p.engancheBase,
pctPagoFinal: comoFraccion(p.pctPagoFinal),
pagoFinalBase: p.pagoFinalBase,
modoMensualidades: p.modoMensualidades,
...(p.engancheCeroSinApartado ? { engancheCeroSinApartado: true } : {}),
}
}),
}
setGuardando(true)
try {
await guardarReglas(desarrollo.id, reglas)
setExito('Reglas guardadas. El cotizador las usará en las próximas propuestas.')
await onRefrescar()
} catch (err) {
setError(err instanceof Error && err.message ? err.message : 'No se pudieron guardar las reglas.')
} finally {
setGuardando(false)
}
}
return (
<div className="space-y-4">
<section className={`${TARJETA} p-4`}>
<h2 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
Parámetros generales
</h2>
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
Estos valores los usa el cotizador al armar las propuestas de este desarrollo.
</p>
{desarrollo.reglas === null && !cargandoVigentes && reglasVigentes !== null && (
<p className="mt-2 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-400">
Este desarrollo no tiene reglas guardadas: se muestran los valores que el cotizador usa
hoy. Al guardar quedarán registrados en la base.
</p>
)}
<div className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Campo
id="reglas-fuente-n"
etiqueta="Fuente de N (mensualidades)"
ayuda="De dónde sale el número de mensualidades de la propuesta."
>
<select
id="reglas-fuente-n"
value={generales.fuenteDeN}
onChange={(e) =>
setGenerales((prev) => ({ ...prev, fuenteDeN: e.target.value as FuenteDeN }))
}
className={INPUT}
>
{(Object.keys(ETIQUETA_FUENTE_N) as FuenteDeN[]).map((fuente) => (
<option key={fuente} value={fuente}>
{ETIQUETA_FUENTE_N[fuente]}
</option>
))}
</select>
</Campo>
<Campo
id="reglas-n-default"
etiqueta="N por defecto"
ayuda="Se usa cuando la unidad no trae fecha de entrega ni meses para firma."
>
<input
id="reglas-n-default"
type="number"
min={0}
value={generales.nDefault}
onChange={(e) => setGenerales((prev) => ({ ...prev, nDefault: e.target.value }))}
className={INPUT}
/>
</Campo>
{generales.fuenteDeN === 'fijo' && (
<Campo
id="reglas-n-fijo"
etiqueta="N fijo"
ayuda="Mensualidades que se usan cuando la fuente de N es fija (entrega inmediata)."
>
<input
id="reglas-n-fijo"
type="number"
min={0}
value={generales.nFijo}
onChange={(e) => setGenerales((prev) => ({ ...prev, nFijo: e.target.value }))}
placeholder="1"
className={INPUT}
/>
</Campo>
)}
<Campo id="reglas-cuota" etiqueta="Cuota de mantenimiento (MXN)">
<input
id="reglas-cuota"
type="number"
min={0}
value={generales.cuotaMantenimiento}
onChange={(e) =>
setGenerales((prev) => ({ ...prev, cuotaMantenimiento: e.target.value }))
}
className={INPUT}
/>
</Campo>
<Campo
id="reglas-prefijo"
etiqueta="Prefijo de unidad en el PDF"
ayuda="Cómo se nombra la unidad en la cotización impresa (CASA, DEPTO…)."
>
<input
id="reglas-prefijo"
type="text"
value={generales.prefijoUnidadPdf}
onChange={(e) =>
setGenerales((prev) => ({ ...prev, prefijoUnidadPdf: e.target.value }))
}
className={INPUT}
/>
</Campo>
<div className="flex items-end">
<label
htmlFor="reglas-precio-unico"
className="flex items-center gap-2 text-sm text-[#374151] dark:text-[#CBD5E1]"
>
<input
id="reglas-precio-unico"
type="checkbox"
checked={generales.precioUnico}
onChange={(e) => setGenerales((prev) => ({ ...prev, precioUnico: e.target.checked }))}
className="h-4 w-4 rounded border-[#D1D5DB] text-accent focus:ring-2 focus:ring-accent/20 dark:border-[#334155]"
/>
Precio único (sin distinguir acabados)
</label>
</div>
</div>
</section>
{PROPUESTAS_BASE.map((base) => {
const p = propuestas[base.tipo]!
const activa = habilitadas[base.tipo]
return (
<section
key={base.tipo}
className={`${TARJETA} p-4 ${activa ? '' : 'opacity-70'}`}
aria-labelledby={`reglas-titulo-${base.tipo}`}
>
<div className="flex flex-wrap items-center justify-between gap-3">
<h3
id={`reglas-titulo-${base.tipo}`}
className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]"
>
{p.titulo}
</h3>
<label
htmlFor={`reglas-activa-${base.tipo}`}
className="flex items-center gap-2 text-xs text-[#4B5563] dark:text-[#CBD5E1]"
>
<input
id={`reglas-activa-${base.tipo}`}
type="checkbox"
checked={activa}
onChange={(e) =>
setHabilitadas((prev) => ({ ...prev, [base.tipo]: e.target.checked }))
}
className="h-4 w-4 rounded border-[#D1D5DB] text-accent focus:ring-2 focus:ring-accent/20 dark:border-[#334155]"
/>
Propuesta activa
</label>
</div>
<p className="mt-2 rounded-lg bg-[#F9FAFB] px-3 py-2 text-xs text-[#475569] dark:bg-[#0F172A] dark:text-[#CBD5E1]">
{resumenDe(p)}
</p>
<div className="mt-4 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Campo id={`reglas-titulo-campo-${base.tipo}`} etiqueta="Título en la propuesta">
<input
id={`reglas-titulo-campo-${base.tipo}`}
type="text"
value={p.titulo}
onChange={(e) => setPropuesta(base.tipo, { titulo: e.target.value })}
className={INPUT}
/>
</Campo>
<Campo
id={`reglas-enganche-${base.tipo}`}
etiqueta="Enganche (%)"
>
<input
id={`reglas-enganche-${base.tipo}`}
type="number"
min={0}
step="any"
value={p.pctEnganche}
onChange={(e) => setPropuesta(base.tipo, { pctEnganche: e.target.value })}
className={INPUT}
/>
</Campo>
<Campo id={`reglas-base-enganche-${base.tipo}`} etiqueta="Base del enganche">
<select
id={`reglas-base-enganche-${base.tipo}`}
value={p.engancheBase}
onChange={(e) =>
setPropuesta(base.tipo, { engancheBase: e.target.value as BasePrecio })
}
className={INPUT}
>
{(['lista', 'final'] as BasePrecio[]).map((opcion) => (
<option key={opcion} value={opcion}>
{ETIQUETA_BASE_OPCION[opcion]}
</option>
))}
</select>
</Campo>
<Campo id={`reglas-pago-final-${base.tipo}`} etiqueta="Pago final (%)">
<input
id={`reglas-pago-final-${base.tipo}`}
type="number"
min={0}
step="any"
value={p.pctPagoFinal}
onChange={(e) => setPropuesta(base.tipo, { pctPagoFinal: e.target.value })}
className={INPUT}
/>
</Campo>
<Campo id={`reglas-base-pago-${base.tipo}`} etiqueta="Base del pago final">
<select
id={`reglas-base-pago-${base.tipo}`}
value={p.pagoFinalBase}
onChange={(e) =>
setPropuesta(base.tipo, { pagoFinalBase: e.target.value as BasePrecio })
}
className={INPUT}
>
{(['lista', 'final'] as BasePrecio[]).map((opcion) => (
<option key={opcion} value={opcion}>
{ETIQUETA_BASE_OPCION[opcion]}
</option>
))}
</select>
</Campo>
<Campo id={`reglas-descuento-${base.tipo}`} etiqueta="Descuento por defecto (%)">
<input
id={`reglas-descuento-${base.tipo}`}
type="number"
min={0}
step="any"
value={p.descuentoDefault}
onChange={(e) => setPropuesta(base.tipo, { descuentoDefault: e.target.value })}
className={INPUT}
/>
</Campo>
<Campo
id={`reglas-modo-${base.tipo}`}
etiqueta="Mensualidades"
className="sm:col-span-2 lg:col-span-3"
>
<select
id={`reglas-modo-${base.tipo}`}
value={p.modoMensualidades}
onChange={(e) =>
setPropuesta(base.tipo, {
modoMensualidades: e.target.value as ModoMensualidades,
})
}
className={INPUT}
>
{MODOS.map((modo) => (
<option key={modo} value={modo}>
{ETIQUETA_MODO[modo]}
</option>
))}
</select>
</Campo>
<div className="flex items-end sm:col-span-2 lg:col-span-3">
<label
htmlFor={`reglas-cero-apartado-${base.tipo}`}
className="flex items-center gap-2 text-sm text-[#374151] dark:text-[#CBD5E1]"
>
<input
id={`reglas-cero-apartado-${base.tipo}`}
type="checkbox"
checked={p.engancheCeroSinApartado}
onChange={(e) =>
setPropuesta(base.tipo, { engancheCeroSinApartado: e.target.checked })
}
className="h-4 w-4 rounded border-[#D1D5DB] text-accent focus:ring-2 focus:ring-accent/20 dark:border-[#334155]"
/>
Con enganche 0% no se descuenta el apartado (Infonavit)
</label>
</div>
</div>
</section>
)
})}
<section className={`${TARJETA} p-4`}>
<h2 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
Resumen para el cotizador
</h2>
{activas.length === 0 ? (
<p className="mt-2 text-sm text-[#64748B] dark:text-[#94A3B8]">
No hay esquemas activos: el cotizador no mostrará propuestas para este desarrollo.
</p>
) : (
<dl className="mt-3 space-y-2">
{activas.map((base) => (
<div key={base.tipo}>
<dt className="text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]">
{propuestas[base.tipo]!.titulo}
</dt>
<dd className="text-sm text-[#111827] dark:text-[#E2E8F0]">
{resumenDe(propuestas[base.tipo]!)}
</dd>
</div>
))}
</dl>
)}
<div className="mt-4 flex flex-col gap-3">
<button
type="button"
onClick={() => void guardar()}
disabled={guardando}
className={`${BOTON_PRIMARIO} self-start`}
>
{guardando ? 'Guardando…' : 'Guardar reglas'}
</button>
{error && <MensajeError>{error}</MensajeError>}
{exito && <MensajeExito>{exito}</MensajeExito>}
</div>
</section>
</div>
)
}

View File

@@ -0,0 +1,86 @@
import type { DesarrolloResumen } from '../../services/desarrollos'
import { BOTON_MINIMO, BadgeEstado, Chip, ETIQUETA_TIPO, TARJETA } from './comun'
interface Props {
desarrollo: DesarrolloResumen
onAbrir: (desarrollo: DesarrolloResumen) => void
onEditar: (desarrollo: DesarrolloResumen) => void
}
/** Tarjeta del listado: al hacer clic (o Enter) abre la ficha del desarrollo */
export function TarjetaDesarrollo({ desarrollo, onAbrir, onEditar }: Props) {
const disponibles = desarrollo.unidadesDisponibles ?? 0
const totales = desarrollo.unidadesTotales ?? 0
const modelos = desarrollo.modelos ?? []
return (
<article className={`relative flex flex-col ${TARJETA} p-4`}>
<button
type="button"
onClick={() => onAbrir(desarrollo)}
aria-label={`Abrir ficha de ${desarrollo.nombre}`}
className="absolute inset-0 rounded-xl"
/>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="truncate text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">
{desarrollo.nombre}
</h3>
<p className="mt-0.5 text-xs text-[#64748B] dark:text-[#94A3B8]">
{desarrollo.clave} · {ETIQUETA_TIPO[desarrollo.tipo] ?? desarrollo.tipo}
</p>
</div>
<BadgeEstado estado={desarrollo.estado} />
</div>
<dl className="mt-3 grid grid-cols-2 gap-3">
<div>
<dt className="text-[11px] text-[#94A3B8] dark:text-[#64748B]">Unidades</dt>
<dd className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">{totales}</dd>
</div>
<div>
<dt className="text-[11px] text-[#94A3B8] dark:text-[#64748B]">Disponibles</dt>
<dd className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">{disponibles}</dd>
</div>
</dl>
<div className="mt-3">
<p className="text-[11px] font-medium uppercase tracking-wider text-[#94A3B8] dark:text-[#64748B]">
Modelos
</p>
<div className="mt-1.5 flex flex-wrap gap-1.5">
{modelos.length === 0 ? (
<span className="text-xs text-[#64748B] dark:text-[#94A3B8]">Sin modelos</span>
) : (
modelos.map((modelo) => (
<Chip key={modelo.id ?? modelo.nombre}>{modelo.nombre}</Chip>
))
)}
</div>
</div>
<div className="mt-3 flex flex-wrap gap-1.5">
{desarrollo.activoEnCotizador ? (
<Chip>En cotizador</Chip>
) : (
<Chip>Oculto en cotizador</Chip>
)}
{desarrollo.activoEnAlta ? <Chip>En altas</Chip> : <Chip>Oculto en altas</Chip>}
{!desarrollo.tieneReglas && <Chip>Sin reglas del cotizador</Chip>}
</div>
<div className="mt-4 flex items-center justify-between gap-2">
<span className="text-xs text-[#64748B] dark:text-[#94A3B8]">Ver ficha →</span>
<button
type="button"
onClick={() => onEditar(desarrollo)}
aria-label={`Editar ${desarrollo.nombre}`}
className={`relative z-10 ${BOTON_MINIMO}`}
>
Editar
</button>
</div>
</article>
)
}

View File

@@ -0,0 +1,189 @@
import type { ReactNode } from 'react'
import type { EstadoDesarrollo, TipoDesarrollo } from '../../services/desarrollos'
// ─── Clases compartidas (mismo lenguaje visual que el resto de la landing) ──
export const TARJETA =
'rounded-xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]'
export const INPUT =
'mt-1.5 w-full rounded-lg border border-[#D1D5DB] bg-white px-3 py-2 text-sm text-[#111827] transition-colors focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]'
export const INPUT_ERROR = 'border-red-500 dark:border-red-500'
export const LABEL = 'block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]'
export const AYUDA = 'mt-1 text-[11px] text-[#64748B] dark:text-[#94A3B8]'
export const TEXTO_ERROR = 'mt-1 text-xs text-red-600 dark:text-red-400'
export const BOTON_PRIMARIO =
'rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover disabled:opacity-50'
export const BOTON_SECUNDARIO =
'rounded-lg border border-[#D1D5DB] bg-white px-4 py-2 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] dark:hover:bg-[#334155]'
export const BOTON_PELIGRO =
'rounded-lg border border-red-300 bg-white px-4 py-2 text-sm font-medium text-red-600 shadow-sm transition-all duration-100 ease-out hover:bg-red-50 disabled:opacity-50 dark:border-red-500/40 dark:bg-[#0F172A] dark:text-red-400 dark:hover:bg-red-500/10'
export const BOTON_MINIMO =
'rounded-lg border border-[#D1D5DB] bg-white px-3 py-1.5 text-xs font-medium text-[#374151] shadow-sm transition-colors hover:bg-[#F9FAFB] disabled:opacity-50 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] dark:hover:bg-[#334155]'
// ─── Etiquetas de dominio ───────────────────────────────────────────
export const ETIQUETA_ESTADO: Record<EstadoDesarrollo, string> = {
ACTIVO: 'Activo',
PREVENTA: 'Preventa',
CERRADO: 'Cerrado',
}
const CLASE_ESTADO: Record<EstadoDesarrollo, string> = {
ACTIVO: 'bg-green-50 text-green-700 dark:bg-green-500/10 dark:text-green-400',
PREVENTA: 'bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400',
CERRADO: 'bg-[#F1F5F9] text-[#475569] dark:bg-[#334155] dark:text-[#CBD5E1]',
}
export const ETIQUETA_TIPO: Record<TipoDesarrollo, string> = {
VERTICAL: 'Vertical',
HORIZONTAL: 'Horizontal',
}
/** Base sobre la que se calcula un componente del precio ('lista' | 'final') */
export const ETIQUETA_BASE = { lista: 'precio de lista', final: 'precio final' } as const
export const ETIQUETA_MODO_MENSUALIDADES = {
remanente: 'sobre el remanente',
remanenteLista: 'sobre el remanente del precio de lista',
porcentaje: 'sobre el remanente en porcentaje',
ninguna: 'sin mensualidades',
} as const
export const ETIQUETA_FUENTE_N = {
fechaEntrega: 'Fecha de entrega de la unidad',
fijo: 'N fijo (entrega inmediata / terminación de obra)',
porUnidad: 'Capturado por unidad',
} as const
/** Estatus posibles del inventario (los mismos que maneja el cotizador) */
export const ESTATUS_UNIDAD = [
'DISPONIBLE',
'APARTADO',
'APARTADA',
'VENDIDO',
'VENDIDA',
'ESCRITURADO',
'BLOQUEADO',
] as const
const CLASE_ESTATUS: Record<string, string> = {
DISPONIBLE: 'bg-green-50 text-green-700 dark:bg-green-500/10 dark:text-green-400',
APARTADO: 'bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400',
APARTADA: 'bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-400',
VENDIDO: 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400',
VENDIDA: 'bg-blue-50 text-blue-700 dark:bg-blue-500/10 dark:text-blue-400',
ESCRITURADO: 'bg-indigo-50 text-indigo-700 dark:bg-indigo-500/10 dark:text-indigo-400',
BLOQUEADO: 'bg-[#F1F5F9] text-[#475569] dark:bg-[#334155] dark:text-[#CBD5E1]',
}
export function BadgeEstado({ estado }: { estado: EstadoDesarrollo }) {
return (
<span
className={`shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium ${CLASE_ESTADO[estado] ?? CLASE_ESTATUS.BLOQUEADO}`}
>
{ETIQUETA_ESTADO[estado] ?? estado}
</span>
)
}
export function BadgeEstatus({ estatus }: { estatus: string | null | undefined }) {
if (!estatus) return <span className="text-xs text-[#94A3B8]">—</span>
return (
<span
className={`shrink-0 rounded-full px-2.5 py-0.5 text-xs font-medium ${CLASE_ESTATUS[estatus] ?? 'bg-[#F1F5F9] text-[#475569] dark:bg-[#334155] dark:text-[#CBD5E1]'}`}
>
{estatus}
</span>
)
}
export function Chip({ children }: { children: ReactNode }) {
return (
<span className="rounded-full bg-[#F1F5F9] px-2.5 py-0.5 text-[11px] font-medium text-[#475569] dark:bg-[#334155] dark:text-[#CBD5E1]">
{children}
</span>
)
}
// ─── Piezas de formulario ───────────────────────────────────────────
export function Campo({
id,
etiqueta,
requerido = false,
ayuda,
error,
className = '',
children,
}: {
id: string
etiqueta: string
requerido?: boolean
ayuda?: string
error?: string
className?: string
children: ReactNode
}) {
return (
<div className={className}>
<label htmlFor={id} className={LABEL}>
{etiqueta}
{requerido && <span aria-hidden="true"> *</span>}
</label>
{children}
{ayuda && <p className={AYUDA}>{ayuda}</p>}
{error && (
<p role="alert" className={TEXTO_ERROR}>
{error}
</p>
)}
</div>
)
}
export function MensajeError({ children }: { children: ReactNode }) {
return (
<p role="alert" className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700 dark:border-red-500/40 dark:bg-red-500/10 dark:text-red-400">
{children}
</p>
)
}
export function MensajeExito({ children }: { children: ReactNode }) {
return (
<p role="status" className="rounded-lg border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700 dark:border-green-500/40 dark:bg-green-500/10 dark:text-green-400">
{children}
</p>
)
}
/** Formato de moneda para precios de inventario */
export function moneda(valor: number | null | undefined): string {
if (valor == null) return '—'
return new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
maximumFractionDigits: 0,
}).format(valor)
}
/** Convierte '' | null | undefined a null y el resto a número (NaN → null) */
export function aNumero(valor: string): number | null {
const limpio = valor.trim()
if (limpio === '') return null
const n = Number(limpio)
return Number.isFinite(n) ? n : null
}
export function aTexto(valor: number | null | undefined): string {
return valor == null ? '' : String(valor)
}

View File

@@ -0,0 +1,46 @@
/**
* Numeración de unidades: el patrón admite `{clave}` (clave del desarrollo) y
* `{n}` (número consecutivo del rango).
*/
export const MAX_UNIDADES_GENERADOR = 1000
export function aplicarPatron(patron: string, clave: string, n: number): string {
return patron.split('{clave}').join(clave).split('{n}').join(String(n))
}
/** Primeros `max` códigos del rango (vista previa antes de confirmar) */
export function generarCodigos(
patron: string,
clave: string,
desde: number,
hasta: number,
max = 5,
): string[] {
if (!patron.includes('{n}') || !Number.isInteger(desde) || !Number.isInteger(hasta) || hasta < desde) {
return []
}
const cantidad = Math.min(hasta - desde + 1, Math.max(0, max))
return Array.from({ length: cantidad }, (_, i) => aplicarPatron(patron, clave, desde + i))
}
/** Total de unidades que crearía el generador con el rango actual */
export function totalDelRango(desde: number, hasta: number): number {
if (!Number.isInteger(desde) || !Number.isInteger(hasta) || hasta < desde) return 0
return hasta - desde + 1
}
/** Devuelve el mensaje de error del generador o null si todo está bien */
export function validarGenerador(patron: string, desde: number, hasta: number): string | null {
if (patron.trim() === '') return 'Escribe el patrón de código de la unidad.'
if (!patron.includes('{n}')) return 'El patrón debe incluir {n} para numerar las unidades.'
if (/[^A-Za-z0-9{}_-]/.test(patron)) {
return 'El patrón solo admite letras, números, guiones, {clave} y {n}.'
}
if (!Number.isInteger(desde) || !Number.isInteger(hasta)) return 'El rango debe usar números enteros.'
if (hasta < desde) return 'El valor "hasta" debe ser mayor o igual que "desde".'
if (hasta - desde + 1 > MAX_UNIDADES_GENERADOR) {
return `El rango no puede superar ${MAX_UNIDADES_GENERADOR} unidades por vez.`
}
return null
}

View File

@@ -0,0 +1,164 @@
import type { BasePrecio, ModoMensualidades, PropuestaReglas, ReglasCotizador } from '../../services/desarrollos'
import { ETIQUETA_BASE, ETIQUETA_MODO_MENSUALIDADES } from './comun'
/**
* Los 4 esquemas comerciales que maneja el cotizador. `tipo` y `claveDescuento`
* son las claves que espera `apps/cotizador` (no cambiarlas sin cambiarlas allá).
*/
export const PROPUESTAS_BASE: { tipo: string; claveDescuento: string; titulo: string }[] = [
{ tipo: 'CREDITO_BANCARIO', claveDescuento: 'creditoBancario', titulo: 'Crédito Bancario' },
{
tipo: 'CREDITO_INFONAVIT_COFINAVIT',
claveDescuento: 'creditoInfonavit',
titulo: 'Crédito Infonavit-Cofinavit',
},
{ tipo: 'CONTADO', claveDescuento: 'contado', titulo: 'Contado' },
{ tipo: 'SEMI_CONTADO', claveDescuento: 'semiContado', titulo: 'Semi Contado' },
]
/** Valores de partida de cada esquema (los del cotizador de Diagonal) */
export const PROPUESTAS_POR_DEFECTO: Record<string, PropuestaReglas> = {
CREDITO_BANCARIO: {
tipo: 'CREDITO_BANCARIO',
claveDescuento: 'creditoBancario',
titulo: 'Crédito Bancario',
descuentoDefault: 0,
pctEnganche: 0.025,
engancheBase: 'lista',
pctPagoFinal: 0.9,
pagoFinalBase: 'final',
modoMensualidades: 'remanente',
},
CREDITO_INFONAVIT_COFINAVIT: {
tipo: 'CREDITO_INFONAVIT_COFINAVIT',
claveDescuento: 'creditoInfonavit',
titulo: 'Crédito Infonavit-Cofinavit',
descuentoDefault: 0,
pctEnganche: 0.025,
engancheBase: 'lista',
pctPagoFinal: 0.95,
pagoFinalBase: 'final',
modoMensualidades: 'remanenteLista',
},
CONTADO: {
tipo: 'CONTADO',
claveDescuento: 'contado',
titulo: 'Contado',
descuentoDefault: 0.02,
pctEnganche: 0.9,
engancheBase: 'final',
pctPagoFinal: 0.1,
pagoFinalBase: 'final',
modoMensualidades: 'ninguna',
},
SEMI_CONTADO: {
tipo: 'SEMI_CONTADO',
claveDescuento: 'semiContado',
titulo: 'Semi Contado',
descuentoDefault: 0.01,
pctEnganche: 0.5,
engancheBase: 'final',
pctPagoFinal: 0.1,
pagoFinalBase: 'final',
modoMensualidades: 'porcentaje',
},
}
export const REGLAS_POR_DEFECTO: ReglasCotizador = {
precioUnico: false,
fuenteDeN: 'fechaEntrega',
nDefault: 12,
cuotaMantenimiento: 0,
prefijoUnidadPdf: 'CASA',
propuestas: [
PROPUESTAS_POR_DEFECTO.CREDITO_BANCARIO!,
PROPUESTAS_POR_DEFECTO.CREDITO_INFONAVIT_COFINAVIT!,
PROPUESTAS_POR_DEFECTO.CONTADO!,
PROPUESTAS_POR_DEFECTO.SEMI_CONTADO!,
],
}
/** Los porcentajes se guardan como fracción (0.025 = 2.5%), igual que el cotizador */
export function pctDeFraccion(fraccion: number): number {
if (!Number.isFinite(fraccion)) return 0
return Number((fraccion * 100).toFixed(4))
}
export function fraccionDePct(pct: number): number {
if (!Number.isFinite(pct)) return 0
return Number((pct / 100).toFixed(6))
}
/** Completa las reglas que llegan del API con los valores por defecto */
export function completarReglas(reglas: ReglasCotizador | null): ReglasCotizador {
if (!reglas) return REGLAS_POR_DEFECTO
return {
precioUnico: reglas.precioUnico ?? REGLAS_POR_DEFECTO.precioUnico,
fuenteDeN: reglas.fuenteDeN ?? REGLAS_POR_DEFECTO.fuenteDeN,
...(reglas.nFijo != null ? { nFijo: reglas.nFijo } : {}),
nDefault: reglas.nDefault ?? REGLAS_POR_DEFECTO.nDefault,
cuotaMantenimiento: reglas.cuotaMantenimiento ?? REGLAS_POR_DEFECTO.cuotaMantenimiento,
prefijoUnidadPdf: reglas.prefijoUnidadPdf ?? REGLAS_POR_DEFECTO.prefijoUnidadPdf,
propuestas: (reglas.propuestas ?? []).map((p) => ({ ...p })),
}
}
/** Completa una propuesta guardada con los valores por defecto de su tipo */
export function completarPropuesta(propuesta: PropuestaReglas): PropuestaReglas {
const base = PROPUESTAS_POR_DEFECTO[propuesta.tipo]
if (!base) return propuesta
return {
...base,
...propuesta,
titulo: propuesta.titulo || base.titulo,
modoMensualidades: propuesta.modoMensualidades ?? base.modoMensualidades,
}
}
const ETIQUETA_MODO_CORTO: Record<ModoMensualidades, string> = {
remanente: 'sobre el remanente',
remanenteLista: 'sobre el remanente del precio de lista',
porcentaje: 'sobre el remanente en porcentaje',
ninguna: 'sin mensualidades',
}
const nombreBase = (base: BasePrecio) => ETIQUETA_BASE[base] ?? base
/** pct legible: 2.5 → "2.5", 90 → "90", 0.125 → "0.125" */
export function formatearPct(valor: number): string {
return String(Number.isFinite(valor) ? Number(valor.toFixed(4)) : 0)
}
/** Resumen legible de una propuesta, p. ej. "Enganche 2.5% del precio de lista, pago final 90%…" */
export function resumenPropuesta(p: PropuestaReglas): string {
const partes = [
`Enganche ${formatearPct(pctDeFraccion(p.pctEnganche))}% del ${nombreBase(p.engancheBase)}`,
`pago final ${formatearPct(pctDeFraccion(p.pctPagoFinal))}% del ${nombreBase(p.pagoFinalBase)}`,
`mensualidades ${ETIQUETA_MODO_CORTO[p.modoMensualidades] ?? ETIQUETA_MODO_MENSUALIDADES[p.modoMensualidades] ?? p.modoMensualidades}`,
]
if (p.descuentoDefault) {
partes.push(`descuento por defecto ${formatearPct(pctDeFraccion(p.descuentoDefault))}%`)
}
return partes.join(', ') + '.'
}
/** Propuestas habilitadas a partir de las reglas guardadas */
export function propuestasHabilitadas(reglas: ReglasCotizador | null): Record<string, boolean> {
const guardadas = new Set((reglas?.propuestas ?? []).map((p) => p.tipo))
const habilitadas: Record<string, boolean> = {}
for (const base of PROPUESTAS_BASE) habilitadas[base.tipo] = guardadas.has(base.tipo)
return habilitadas
}
/** Estado inicial del editor: las 4 propuestas, habilitadas según lo guardado */
export function propuestasEditables(reglas: ReglasCotizador | null): Record<string, PropuestaReglas> {
const porTipo = new Map((reglas?.propuestas ?? []).map((p) => [p.tipo, p]))
const editables: Record<string, PropuestaReglas> = {}
for (const base of PROPUESTAS_BASE) {
const guardada = porTipo.get(base.tipo)
editables[base.tipo] = guardada
? completarPropuesta(guardada)
: { ...PROPUESTAS_POR_DEFECTO[base.tipo]! }
}
return editables
}

View File

@@ -0,0 +1,140 @@
import { useState } from 'react'
import { ArrowRight, CalendarDays, MapPin, MessageCircle } from 'lucide-react'
import type { EstadoDesarrollo, TipoDesarrollo } from '../../services/publico'
import { etiquetaEstado, mesAnio, normalizarTexto } from './formato'
interface Props {
nombre: string
zona?: string | null
direccion?: string | null
estado: EstadoDesarrollo
tipo: TipoDesarrollo
fechaEntregaProgramada?: string | null
imagenUrl?: string | null
logoUrl?: string | null
/** URL de WhatsApp ya armada (ver components/publico/contacto.ts) */
urlWhatsApp: string
onVerDisponibilidad: () => void
}
const ETIQUETA_TIPO: Record<TipoDesarrollo, string> = {
VERTICAL: 'Desarrollo vertical',
HORIZONTAL: 'Desarrollo horizontal',
}
const CLASE_ESTADO: Record<EstadoDesarrollo, string> = {
PREVENTA: 'border-white/30 bg-[#F59E0B]/20 text-[#FDE68A]',
ACTIVO: 'border-white/30 bg-[#10B981]/20 text-[#A7F3D0]',
CERRADO: 'border-white/25 bg-white/10 text-white/80',
}
/** Encabezado tipo hero: imagen del desarrollo, identidad, estado y CTA principal */
export function EncabezadoDesarrollo({
nombre,
zona,
direccion,
estado,
tipo,
fechaEntregaProgramada,
imagenUrl,
logoUrl,
urlWhatsApp,
onVerDisponibilidad,
}: Props) {
const [imagenRota, setImagenRota] = useState(false)
const mostrarImagen = Boolean(imagenUrl) && !imagenRota
const entrega = mesAnio(fechaEntregaProgramada)
const ubicacion = [zona, direccion].filter((v) => v && normalizarTexto(v)).join(' · ')
return (
<header className="relative isolate overflow-hidden bg-[#0B1220] text-white">
{mostrarImagen ? (
<>
<img
src={imagenUrl as string}
alt={`Vista de ${nombre}`}
onError={() => setImagenRota(true)}
className="absolute inset-0 h-full w-full object-cover object-center"
/>
<div
aria-hidden="true"
className="absolute inset-0 bg-gradient-to-t from-[#0B1220] via-[#0B1220]/85 to-[#0B1220]/45"
/>
<div
aria-hidden="true"
className="absolute inset-0 bg-gradient-to-r from-[#0B1220]/85 via-[#0B1220]/40 to-transparent"
/>
</>
) : (
<>
<div
aria-hidden="true"
className="absolute inset-0 bg-gradient-to-br from-[#0F172A] via-[#1E3A8A] to-[#2563EB]"
/>
<div
aria-hidden="true"
className="absolute inset-0 opacity-40 [background-image:radial-gradient(circle_at_20%_20%,rgba(255,255,255,0.18),transparent_55%),radial-gradient(circle_at_80%_0%,rgba(255,255,255,0.12),transparent_45%)]"
/>
</>
)}
<div className="relative mx-auto flex min-h-[540px] w-full max-w-6xl flex-col justify-end px-4 pb-14 pt-16 sm:px-6 sm:pb-20 sm:pt-24 lg:min-h-[620px] lg:px-8">
{logoUrl ? (
<img
src={logoUrl}
alt={`Logotipo de ${nombre}`}
className="mb-6 h-10 w-auto max-w-[180px] object-contain object-left sm:h-12"
/>
) : null}
<div className="flex flex-wrap items-center gap-2">
<span
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-semibold tracking-wide uppercase ${CLASE_ESTADO[estado]}`}
>
{etiquetaEstado(estado)}
</span>
{entrega ? (
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/25 bg-white/10 px-3 py-1 text-xs font-medium text-white/90 backdrop-blur-sm">
<CalendarDays aria-hidden="true" className="h-3.5 w-3.5" />
Entrega: {entrega}
</span>
) : null}
<span className="inline-flex items-center rounded-full border border-white/20 bg-white/5 px-3 py-1 text-xs font-medium text-white/75">
{ETIQUETA_TIPO[tipo]}
</span>
</div>
<h1 className="mt-5 max-w-3xl text-3xl leading-tight font-semibold tracking-tight text-white sm:text-4xl lg:text-5xl">
{nombre}
</h1>
{ubicacion ? (
<p className="mt-4 flex items-center gap-2 text-sm text-white/80 sm:text-base">
<MapPin aria-hidden="true" className="h-4 w-4 shrink-0 text-accent" />
<span>{ubicacion}</span>
</p>
) : null}
<div className="mt-8 flex flex-col gap-3 sm:flex-row sm:items-center">
<a
href={urlWhatsApp}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 rounded-xl bg-[#25D366] px-6 py-3.5 text-sm font-semibold text-[#06281A] shadow-lg shadow-black/20 transition-colors duration-150 ease-out hover:bg-[#1FBE5A]"
>
<MessageCircle aria-hidden="true" className="h-4 w-4" />
Escríbenos por WhatsApp
</a>
<button
type="button"
onClick={onVerDisponibilidad}
className="inline-flex items-center justify-center gap-2 rounded-xl border border-white/35 bg-white/10 px-6 py-3.5 text-sm font-semibold text-white backdrop-blur-sm transition-colors duration-150 ease-out hover:bg-white/20"
>
Ver disponibilidad
<ArrowRight aria-hidden="true" className="h-4 w-4" />
</button>
</div>
</div>
</header>
)
}

View File

@@ -0,0 +1,45 @@
import Skeleton from '../Skeleton'
/** Estado de carga del micrositio: mismos bloques que la página real */
export function EsqueletoDesarrollo() {
return (
<div aria-busy="true" aria-live="polite" role="status">
<span className="sr-only">Cargando información del desarrollo…</span>
<div className="bg-[#0B1220]">
<div className="mx-auto flex min-h-[420px] w-full max-w-6xl flex-col justify-end px-4 pb-16 sm:px-6 lg:px-8">
<Skeleton className="h-6 w-28 bg-white/10" />
<Skeleton className="mt-6 h-10 w-3/4 max-w-xl bg-white/10" />
<Skeleton className="mt-4 h-4 w-1/2 max-w-sm bg-white/10" />
<Skeleton className="mt-8 h-12 w-full max-w-md bg-white/10" />
</div>
</div>
<div className="mx-auto w-full max-w-6xl px-4 sm:px-6 lg:px-8">
<div className="-mt-10 grid grid-cols-2 gap-px overflow-hidden rounded-2xl border border-[#E5E7EB] bg-[#E5E7EB] sm:-mt-12 lg:grid-cols-4 dark:border-[#1E293B] dark:bg-[#1E293B]">
{[0, 1, 2, 3].map((i) => (
<div key={i} className="bg-white p-5 sm:p-6 dark:bg-[#0F172A]">
<Skeleton className="h-3 w-24" />
<Skeleton className="mt-3 h-7 w-16" />
</div>
))}
</div>
<div className="grid gap-10 py-16 lg:grid-cols-2">
<div className="space-y-4">
<Skeleton className="h-8 w-52" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-11/12" />
<Skeleton className="h-4 w-4/5" />
</div>
<Skeleton className="h-48 w-full rounded-2xl" />
</div>
<div className="grid gap-5 pb-16 sm:grid-cols-2 lg:grid-cols-3">
{[0, 1, 2].map((i) => (
<Skeleton key={i} className="h-56 w-full rounded-2xl" />
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,70 @@
import { AlertTriangle, Compass, RefreshCw } from 'lucide-react'
interface Props {
urlWhatsApp: string
}
/** Pantalla elegante para enlaces inválidos o desarrollos que ya no están publicados */
export function DesarrolloNoEncontrado({ urlWhatsApp }: Props) {
return (
<main className="flex min-h-screen flex-col items-center justify-center bg-[#0B1220] px-6 py-20 text-center text-white">
<span
aria-hidden="true"
className="flex h-14 w-14 items-center justify-center rounded-2xl border border-white/15 bg-white/5"
>
<Compass className="h-6 w-6 text-accent" />
</span>
<h1 className="mt-6 text-3xl font-semibold tracking-tight sm:text-4xl">
Desarrollo no encontrado
</h1>
<p className="mt-4 max-w-md text-sm leading-relaxed text-white/70">
El enlace que abriste no corresponde a un desarrollo publicado. Puede que la clave haya
cambiado o que el proyecto ya no esté disponible en línea.
</p>
<a
href={urlWhatsApp}
target="_blank"
rel="noopener noreferrer"
className="mt-8 inline-flex items-center justify-center rounded-xl bg-[#25D366] px-6 py-3.5 text-sm font-semibold text-[#06281A] shadow-lg shadow-black/30 transition-colors duration-150 ease-out hover:bg-[#1FBE5A]"
>
Preguntar por WhatsApp
</a>
<p className="mt-6 text-xs text-white/50">
Si recibiste este enlace de un asesor, pídele que lo verifique.
</p>
</main>
)
}
interface PropsError {
mensaje: string
onReintentar: () => void
}
/** Error de red o del servicio público: se puede reintentar sin recargar */
export function EstadoErrorDesarrollo({ mensaje, onReintentar }: PropsError) {
return (
<main className="flex min-h-screen flex-col items-center justify-center bg-[#F8FAFC] px-6 py-20 text-center dark:bg-[#0F172A]">
<span
aria-hidden="true"
className="flex h-14 w-14 items-center justify-center rounded-2xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]"
>
<AlertTriangle className="h-6 w-6 text-amber-500" />
</span>
<h1 className="mt-6 text-2xl font-semibold tracking-tight text-[#111827] sm:text-3xl dark:text-white">
No pudimos cargar el desarrollo
</h1>
<p className="mt-4 max-w-md text-sm leading-relaxed text-[#64748B] dark:text-[#94A3B8]">
{mensaje}
</p>
<button
type="button"
onClick={onReintentar}
className="mt-8 inline-flex items-center justify-center gap-2 rounded-xl bg-accent px-6 py-3.5 text-sm font-semibold text-white shadow-sm transition-colors duration-150 ease-out hover:bg-accent-hover"
>
<RefreshCw aria-hidden="true" className="h-4 w-4" />
Reintentar
</button>
</main>
)
}

View File

@@ -0,0 +1,36 @@
interface Props {
unidades: number
modelos: number
entrega: string
zona: string
}
/** Franja de datos clave con números grandes (se monta sobre el hero) */
export function FranjaDatos({ unidades, modelos, entrega, zona }: Props) {
const datos = [
{ etiqueta: 'Unidades disponibles', valor: String(unidades) },
{ etiqueta: 'Modelos', valor: String(modelos) },
{ etiqueta: 'Entrega estimada', valor: entrega },
{ etiqueta: 'Zona', valor: zona },
]
return (
<section
aria-label="Datos clave del desarrollo"
className="mx-auto w-full max-w-6xl px-4 sm:px-6 lg:px-8"
>
<dl className="-mt-10 grid grid-cols-2 gap-px overflow-hidden rounded-2xl border border-[#E5E7EB] bg-[#E5E7EB] shadow-lg shadow-black/5 sm:-mt-12 lg:grid-cols-4 dark:border-[#1E293B] dark:bg-[#1E293B] dark:shadow-black/20">
{datos.map((dato) => (
<div key={dato.etiqueta} className="bg-white p-5 sm:p-6 dark:bg-[#0F172A]">
<dt className="text-xs font-medium tracking-wide text-[#64748B] uppercase dark:text-[#94A3B8]">
{dato.etiqueta}
</dt>
<dd className="mt-2 text-2xl font-semibold tracking-tight text-[#111827] sm:text-3xl dark:text-white">
{dato.valor}
</dd>
</div>
))}
</dl>
</section>
)
}

View File

@@ -0,0 +1,81 @@
import { Mail, MessageCircle } from 'lucide-react'
import { telefonoLegible } from './contacto'
interface Props {
nombreDesarrollo: string
urlWhatsApp: string
urlCorreo: string
telefono: string
correo: string
}
/** Pie con los canales de contacto y el aviso de precios sujetos a cambio */
export function PieContacto({
nombreDesarrollo,
urlWhatsApp,
urlCorreo,
telefono,
correo,
}: Props) {
return (
<footer className="bg-[#0B1220] text-white/80">
<div className="mx-auto w-full max-w-6xl px-4 py-14 sm:px-6 sm:py-16 lg:px-8">
<div className="grid gap-10 lg:grid-cols-[1.4fr_1fr]">
<div>
<p className="text-xs font-semibold tracking-[0.18em] text-accent uppercase">
Agenda tu visita
</p>
<h2 className="mt-3 text-2xl font-semibold tracking-tight text-white sm:text-3xl">
Conoce {nombreDesarrollo} en persona
</h2>
<p className="mt-4 max-w-xl text-sm leading-relaxed text-white/70">
Nuestro equipo de ventas te acompaña en todo el proceso: disponibilidad vigente,
formas de pago, créditos y entrega.
</p>
<div className="mt-6 flex flex-col gap-3 sm:flex-row">
<a
href={urlWhatsApp}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 rounded-xl bg-[#25D366] px-6 py-3.5 text-sm font-semibold text-[#06281A] shadow-lg shadow-black/30 transition-colors duration-150 ease-out hover:bg-[#1FBE5A]"
>
<MessageCircle aria-hidden="true" className="h-4 w-4" />
WhatsApp {telefonoLegible(telefono)}
</a>
<a
href={urlCorreo}
className="inline-flex items-center justify-center gap-2 rounded-xl border border-white/25 bg-white/5 px-6 py-3.5 text-sm font-semibold text-white transition-colors duration-150 ease-out hover:bg-white/15"
>
<Mail aria-hidden="true" className="h-4 w-4" />
Escribir un correo
</a>
</div>
<p className="mt-4 text-sm text-white/60">{correo}</p>
</div>
<div>
<h3 className="text-sm font-semibold text-white">Antes de decidir</h3>
<ul className="mt-4 space-y-3 text-sm leading-relaxed text-white/70">
<li>
Precios de lista en pesos mexicanos, sujetos a cambio sin previo aviso y sin
apartado.
</li>
<li>
La disponibilidad mostrada corresponde al inventario publicado al día de hoy y puede
cambiar.
</li>
<li>Imágenes y renders son ilustrativos y no forman parte del contrato.</li>
</ul>
</div>
</div>
<div className="mt-12 border-t border-white/10 pt-6 text-xs text-white/50">
<p>
© {new Date().getFullYear()} FRASA Desarrollos. Todos los derechos reservados.
{nombreDesarrollo ? ` · ${nombreDesarrollo}` : ''}
</p>
</div>
</div>
</footer>
)
}

View File

@@ -0,0 +1,69 @@
import { Check, Sparkles } from 'lucide-react'
import { separarAmenidades } from './formato'
interface Props {
descripcion?: string | null
amenidades?: string | null
}
/** Descripción del desarrollo a dos columnas + amenidades como chips con palomita */
export function SeccionDescripcion({ descripcion, amenidades }: Props) {
const parrafos = (descripcion ?? '')
.split(/\n+/)
.map((p) => p.trim())
.filter(Boolean)
const listaAmenidades = separarAmenidades(amenidades)
if (parrafos.length === 0 && listaAmenidades.length === 0) return null
return (
<section
aria-labelledby="titulo-descripcion"
className="mx-auto w-full max-w-6xl px-4 py-16 sm:px-6 sm:py-20 lg:px-8"
>
<div className="grid gap-10 lg:grid-cols-2 lg:gap-14">
<div>
<p className="text-xs font-semibold tracking-[0.18em] text-accent uppercase">El desarrollo</p>
<h2
id="titulo-descripcion"
className="mt-3 text-2xl font-semibold tracking-tight text-[#111827] sm:text-3xl dark:text-white"
>
Sobre el desarrollo
</h2>
<div className="mt-5 space-y-4 text-base leading-relaxed text-[#4B5563] dark:text-[#CBD5E1]">
{parrafos.length > 0 ? (
parrafos.map((parrafo, indice) => <p key={indice}>{parrafo}</p>)
) : (
<p>
Escríbenos para conocer a detalle las características, acabados y formas de pago de
este desarrollo.
</p>
)}
</div>
</div>
{listaAmenidades.length > 0 ? (
<div className="rounded-2xl border border-[#E5E7EB] bg-white p-6 shadow-sm sm:p-8 dark:border-[#1E293B] dark:bg-[#1E293B]">
<h3 className="flex items-center gap-2 text-base font-semibold text-[#111827] dark:text-white">
<Sparkles aria-hidden="true" className="h-4 w-4 text-accent" />
Amenidades
</h3>
<ul className="mt-5 grid gap-3 sm:grid-cols-2">
{listaAmenidades.map((amenidad) => (
<li key={amenidad} className="flex items-start gap-2.5 text-sm text-[#374151] dark:text-[#CBD5E1]">
<span
aria-hidden="true"
className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-accent/10 text-accent dark:bg-accent/20"
>
<Check className="h-3.5 w-3.5" strokeWidth={3} />
</span>
<span>{amenidad}</span>
</li>
))}
</ul>
</div>
) : null}
</div>
</section>
)
}

View File

@@ -0,0 +1,39 @@
import type { ModeloPublico } from '../../services/publico'
import { TarjetaModelo } from './TarjetaModelo'
interface Props {
modelos: ModeloPublico[]
onVerUnidades: () => void
}
/** Modelos del desarrollo en tarjetas (1 → 2 → 3 columnas) */
export function SeccionModelos({ modelos, onVerUnidades }: Props) {
if (modelos.length === 0) return null
return (
<section
aria-labelledby="titulo-modelos"
className="bg-white py-16 sm:py-20 dark:bg-[#0B1220]"
>
<div className="mx-auto w-full max-w-6xl px-4 sm:px-6 lg:px-8">
<p className="text-xs font-semibold tracking-[0.18em] text-accent uppercase">Tipologías</p>
<h2
id="titulo-modelos"
className="mt-3 text-2xl font-semibold tracking-tight text-[#111827] sm:text-3xl dark:text-white"
>
Modelos disponibles
</h2>
<p className="mt-3 max-w-2xl text-base leading-relaxed text-[#64748B] dark:text-[#94A3B8]">
Compara las tipologías y elige la que mejor se adapte a ti. Los precios son de lista y
pueden variar según ubicación, nivel y acabados.
</p>
<ul className="mt-8 grid gap-5 sm:grid-cols-2 lg:grid-cols-3">
{modelos.map((modelo) => (
<TarjetaModelo key={modelo.nombre} modelo={modelo} onVerUnidades={onVerUnidades} />
))}
</ul>
</div>
</section>
)
}

View File

@@ -0,0 +1,318 @@
import { useEffect, useMemo, useState } from 'react'
import { ArrowUpDown, MessageCircle, Search, SlidersHorizontal } from 'lucide-react'
import type { UnidadPublica } from '../../services/publico'
import { moneda, monedaOTexto, normalizarTexto, superficie } from './formato'
interface Props {
unidades: UnidadPublica[]
urlWhatsApp: string
}
/** Unidades visibles antes de cada "Mostrar más" (hay desarrollos con 90+ unidades) */
const POR_PAGINA = 12
interface Rango {
id: string
desde: number
hasta: number
etiqueta: string
}
/** Tres rangos de precio derivados del inventario publicado */
function rangosDePrecio(unidades: UnidadPublica[]): Rango[] {
const precios = unidades.map((u) => u.precioLista).filter((p) => Number.isFinite(p) && p > 0)
if (precios.length === 0) return []
const minimo = Math.min(...precios)
const maximo = Math.max(...precios)
if (maximo <= minimo) return []
const paso = (maximo - minimo) / 3
return [0, 1, 2].map((i) => {
const desde = minimo + paso * i
const hasta = i === 2 ? maximo : minimo + paso * (i + 1)
return {
id: `r${i}`,
desde,
hasta,
etiqueta:
i === 0
? `Hasta ${moneda(hasta)}`
: i === 2
? `Más de ${moneda(desde)}`
: `${moneda(desde)} – ${moneda(hasta)}`,
}
})
}
const claseControl =
'h-10 w-full rounded-xl border border-[#D1D5DB] bg-white px-3 text-sm text-[#111827] transition-colors duration-150 focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/20 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0]'
/** Inventario disponible: filtrable por modelo, rango de precio y código; ordenable por precio */
export function TablaDisponibilidad({ unidades, urlWhatsApp }: Props) {
const [busqueda, setBusqueda] = useState('')
const [modelo, setModelo] = useState('')
const [rango, setRango] = useState('')
const [ascendente, setAscendente] = useState(true)
const [limite, setLimite] = useState(POR_PAGINA)
const modelos = useMemo(
() => Array.from(new Set(unidades.map((u) => u.modelo))).sort((a, b) => a.localeCompare(b, 'es')),
[unidades],
)
const rangos = useMemo(() => rangosDePrecio(unidades), [unidades])
const filtradas = useMemo(() => {
const texto = normalizarTexto(busqueda)
const activo = rangos.find((r) => r.id === rango)
return unidades
.filter((u) => (modelo ? u.modelo === modelo : true))
.filter((u) => {
if (!activo) return true
if (activo.id === 'r0') return u.precioLista <= activo.hasta
if (activo.id === 'r2') return u.precioLista > activo.desde
return u.precioLista > activo.desde && u.precioLista <= activo.hasta
})
.filter((u) => (texto ? normalizarTexto(u.codigo).includes(texto) : true))
.sort((a, b) => {
const diferencia = a.precioLista - b.precioLista
const orden = diferencia !== 0 ? diferencia : a.codigo.localeCompare(b.codigo, 'es')
return ascendente ? orden : -orden
})
}, [unidades, busqueda, modelo, rango, rangos, ascendente])
// Al cambiar cualquier filtro u orden se vuelve a la primera tanda de resultados
useEffect(() => {
setLimite(POR_PAGINA)
}, [busqueda, modelo, rango, ascendente])
const visibles = filtradas.slice(0, limite)
const restantes = filtradas.length - visibles.length
const hayFiltros = Boolean(busqueda || modelo || rango)
const limpiarFiltros = () => {
setBusqueda('')
setModelo('')
setRango('')
}
return (
<section
id="disponibilidad"
aria-labelledby="titulo-disponibilidad"
className="scroll-mt-6 bg-[#F8FAFC] py-16 sm:py-20 dark:bg-[#0F172A]"
>
<div className="mx-auto w-full max-w-6xl px-4 sm:px-6 lg:px-8">
<p className="text-xs font-semibold tracking-[0.18em] text-accent uppercase">Inventario</p>
<h2
id="titulo-disponibilidad"
className="mt-3 text-2xl font-semibold tracking-tight text-[#111827] sm:text-3xl dark:text-white"
>
Disponibilidad
</h2>
<p className="mt-3 max-w-2xl text-base leading-relaxed text-[#64748B] dark:text-[#94A3B8]">
Consulta las unidades libres al día de hoy, filtra por modelo o precio y ordena por precio.
</p>
{unidades.length === 0 ? (
<p className="mt-8 rounded-2xl border border-[#E5E7EB] bg-white p-6 text-sm text-[#4B5563] shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B] dark:text-[#CBD5E1]">
Por el momento no hay unidades publicadas en línea.{' '}
<a
href={urlWhatsApp}
target="_blank"
rel="noopener noreferrer"
className="font-medium text-accent hover:text-accent-hover"
>
Escríbenos por WhatsApp
</a>{' '}
y te compartimos el inventario actualizado.
</p>
) : (
<>
<div className="mt-8 flex flex-col gap-3 rounded-2xl border border-[#E5E7EB] bg-white p-4 shadow-sm sm:flex-row sm:flex-wrap sm:items-center dark:border-[#1E293B] dark:bg-[#1E293B]">
<div className="relative w-full sm:max-w-xs sm:flex-1">
<Search
aria-hidden="true"
className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-[#94A3B8]"
/>
<input
type="search"
value={busqueda}
onChange={(e) => setBusqueda(e.target.value)}
placeholder="Buscar por código (ej. A-101)"
aria-label="Buscar por código"
className={`${claseControl} pl-9`}
/>
</div>
<select
value={modelo}
onChange={(e) => setModelo(e.target.value)}
aria-label="Filtrar por modelo"
className={`${claseControl} sm:w-48`}
>
<option value="">Todos los modelos</option>
{modelos.map((nombre) => (
<option key={nombre} value={nombre}>
{nombre}
</option>
))}
</select>
{rangos.length > 0 ? (
<select
value={rango}
onChange={(e) => setRango(e.target.value)}
aria-label="Rango de precio"
className={`${claseControl} sm:w-52`}
>
<option value="">Todos los precios</option>
{rangos.map((r) => (
<option key={r.id} value={r.id}>
{r.etiqueta}
</option>
))}
</select>
) : null}
<button
type="button"
onClick={() => setAscendente((valor) => !valor)}
className="inline-flex h-10 items-center justify-center gap-2 rounded-xl border border-[#D1D5DB] bg-white px-4 text-sm font-medium text-[#374151] transition-colors duration-150 ease-out hover:border-accent hover:text-accent dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1]"
>
<ArrowUpDown aria-hidden="true" className="h-4 w-4" />
Precio: {ascendente ? 'menor a mayor' : 'mayor a menor'}
</button>
{hayFiltros ? (
<button
type="button"
onClick={limpiarFiltros}
className="h-10 text-sm font-medium text-[#64748B] transition-colors duration-150 hover:text-accent dark:text-[#94A3B8]"
>
Limpiar filtros
</button>
) : null}
</div>
<p className="mt-4 text-sm text-[#64748B] dark:text-[#94A3B8]" aria-live="polite">
{filtradas.length} {filtradas.length === 1 ? 'unidad' : 'unidades'}
{hayFiltros ? ` de ${unidades.length}` : ''}
</p>
{filtradas.length === 0 ? (
<div className="mt-4 rounded-2xl border border-[#E5E7EB] bg-white p-8 text-center shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<SlidersHorizontal aria-hidden="true" className="mx-auto h-6 w-6 text-[#94A3B8]" />
<p className="mt-3 text-sm text-[#4B5563] dark:text-[#CBD5E1]">
No hay unidades que coincidan con tu búsqueda.
</p>
<button
type="button"
onClick={limpiarFiltros}
className="mt-4 text-sm font-medium text-accent transition-colors duration-150 hover:text-accent-hover"
>
Limpiar filtros
</button>
</div>
) : (
<div className="mt-4 overflow-hidden rounded-2xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
<table className="w-full border-collapse text-left text-sm">
<caption className="sr-only">Unidades disponibles con precio de lista</caption>
<thead className="hidden bg-[#F8FAFC] text-xs tracking-wide text-[#64748B] uppercase md:table-header-group dark:bg-[#0F172A] dark:text-[#94A3B8]">
<tr>
<th scope="col" className="px-5 py-3 font-semibold">Código</th>
<th scope="col" className="px-5 py-3 font-semibold">Modelo</th>
<th scope="col" className="px-5 py-3 font-semibold">Nivel</th>
<th scope="col" className="px-5 py-3 font-semibold">Superficie</th>
<th scope="col" className="px-5 py-3 text-right font-semibold">Precio de lista</th>
<th scope="col" className="px-5 py-3 text-right font-semibold">Con acabados</th>
</tr>
</thead>
<tbody>
{visibles.map((unidad) => (
<tr
key={unidad.codigo}
className="grid grid-cols-2 gap-x-4 gap-y-3 border-b border-[#E5E7EB] p-4 transition-colors duration-100 last:border-b-0 hover:bg-[#F8FAFC] md:table-row md:p-0 dark:border-[#334155] dark:hover:bg-[#0F172A]"
>
<td className="col-span-2 md:table-cell md:px-5 md:py-3.5">
<span className="block text-[11px] font-semibold tracking-wide text-[#94A3B8] uppercase md:hidden">
Código
</span>
<span
data-testid="codigo-unidad"
className="font-semibold text-[#111827] dark:text-white"
>
{unidad.codigo}
</span>
</td>
<td className="md:table-cell md:px-5 md:py-3.5">
<span className="block text-[11px] font-semibold tracking-wide text-[#94A3B8] uppercase md:hidden">
Modelo
</span>
<span className="text-[#374151] dark:text-[#CBD5E1]">{unidad.modelo}</span>
</td>
<td className="md:table-cell md:px-5 md:py-3.5">
<span className="block text-[11px] font-semibold tracking-wide text-[#94A3B8] uppercase md:hidden">
Nivel
</span>
<span className="text-[#374151] dark:text-[#CBD5E1]">{unidad.nivel ?? '—'}</span>
</td>
<td className="md:table-cell md:px-5 md:py-3.5">
<span className="block text-[11px] font-semibold tracking-wide text-[#94A3B8] uppercase md:hidden">
Superficie
</span>
<span className="text-[#374151] dark:text-[#CBD5E1]">
{superficie(unidad.supConstruccion) ?? '—'}
</span>
</td>
<td className="md:table-cell md:px-5 md:py-3.5 md:text-right">
<span className="block text-[11px] font-semibold tracking-wide text-[#94A3B8] uppercase md:hidden">
Precio de lista
</span>
<span className="font-medium text-[#111827] dark:text-white">
{monedaOTexto(unidad.precioLista)}
</span>
</td>
<td className="md:table-cell md:px-5 md:py-3.5 md:text-right">
<span className="block text-[11px] font-semibold tracking-wide text-[#94A3B8] uppercase md:hidden">
Con acabados
</span>
<span className="text-[#374151] dark:text-[#CBD5E1]">
{monedaOTexto(unidad.precioAcabados)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{restantes > 0 ? (
<div className="mt-6 flex justify-center">
<button
type="button"
onClick={() => setLimite((valor) => valor + POR_PAGINA)}
className="inline-flex items-center justify-center rounded-xl border border-[#D1D5DB] bg-white px-6 py-3 text-sm font-semibold text-[#374151] shadow-sm transition-colors duration-150 ease-out hover:border-accent hover:text-accent dark:border-[#334155] dark:bg-[#1E293B] dark:text-[#CBD5E1]"
>
Mostrar más unidades ({restantes} restantes)
</button>
</div>
) : null}
<p className="mt-8 flex flex-wrap items-center gap-2 text-sm text-[#64748B] dark:text-[#94A3B8]">
¿Buscas algo en específico?
<a
href={urlWhatsApp}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 font-medium text-accent transition-colors duration-150 hover:text-accent-hover"
>
<MessageCircle aria-hidden="true" className="h-4 w-4" />
Pregunta por disponibilidad en tiempo real
</a>
</p>
</>
)}
</div>
</section>
)
}

View File

@@ -0,0 +1,83 @@
import { Bath, BedDouble, Car, Ruler } from 'lucide-react'
import type { ModeloPublico } from '../../services/publico'
import { moneda, superficie } from './formato'
interface Props {
modelo: ModeloPublico
onVerUnidades?: () => void
}
/** Tarjeta de un modelo con sus características, precio desde y disponibilidad */
export function TarjetaModelo({ modelo, onVerUnidades }: Props) {
const agotado = modelo.disponibles <= 0
const desde = moneda(modelo.precioDesde)
const sup = superficie(modelo.supConstruccion)
const caracteristicas = [
{ icono: BedDouble, etiqueta: 'Recámaras', valor: modelo.recamaras, sufijo: 'rec.' },
{ icono: Bath, etiqueta: 'Baños', valor: modelo.banos, sufijo: 'baños' },
{ icono: Car, etiqueta: 'Estacionamientos', valor: modelo.estacionamientos, sufijo: 'auto' },
].filter((c) => c.valor !== null && c.valor !== undefined)
return (
<li
className={`flex flex-col rounded-2xl border border-[#E5E7EB] bg-white p-6 shadow-sm transition-all duration-200 ease-out hover:shadow-md dark:border-[#1E293B] dark:bg-[#1E293B] ${
agotado ? 'opacity-60 hover:shadow-sm' : 'hover:border-accent/40'
}`}
>
<div className="flex items-start justify-between gap-3">
<h3 className="text-lg font-semibold tracking-tight text-[#111827] dark:text-white">
{modelo.nombre}
</h3>
{agotado ? (
<span className="shrink-0 rounded-full border border-[#E5E7EB] bg-[#F8FAFC] px-2.5 py-0.5 text-xs font-semibold text-[#64748B] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#94A3B8]">
Agotado
</span>
) : (
<span className="shrink-0 rounded-full bg-emerald-50 px-2.5 py-0.5 text-xs font-semibold text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300">
{modelo.disponibles} {modelo.disponibles === 1 ? 'disponible' : 'disponibles'}
</span>
)}
</div>
<dl className="mt-5 grid grid-cols-2 gap-3 text-sm text-[#4B5563] dark:text-[#CBD5E1]">
{caracteristicas.map(({ icono: Icono, etiqueta, valor, sufijo }) => (
<div key={etiqueta} className="flex items-center gap-2">
<Icono aria-hidden="true" className="h-4 w-4 shrink-0 text-accent" />
<dt className="sr-only">{etiqueta}</dt>
<dd>
{valor} {sufijo === 'auto' && valor === 1 ? 'auto' : sufijo}
</dd>
</div>
))}
{sup ? (
<div className="flex items-center gap-2">
<Ruler aria-hidden="true" className="h-4 w-4 shrink-0 text-accent" />
<dt className="sr-only">Superficie</dt>
<dd>{sup}</dd>
</div>
) : null}
</dl>
<div className="mt-6 flex items-end justify-between gap-3 border-t border-[#F1F5F9] pt-5 dark:border-[#334155]">
<div>
<p className="text-xs font-medium tracking-wide text-[#64748B] uppercase dark:text-[#94A3B8]">
Desde
</p>
<p className="mt-1 text-xl font-semibold tracking-tight text-[#111827] dark:text-white">
{desde ?? 'Precio a consultar'}
</p>
</div>
{onVerUnidades ? (
<button
type="button"
onClick={onVerUnidades}
className="text-sm font-medium text-accent transition-colors duration-150 ease-out hover:text-accent-hover"
>
Ver unidades <span aria-hidden="true">&rarr;</span>
</button>
) : null}
</div>
</li>
)
}

View File

@@ -0,0 +1,75 @@
// ─────────────────────────────────────────────────────────────────────
// CONTACTO DEL MICROSITIO PÚBLICO ⚠️ CAMBIAR AQUÍ
// Este es el único lugar donde se configura a dónde llegan los prospectos
// que entran al micrositio de un desarrollo (/d/:clave).
// ─────────────────────────────────────────────────────────────────────
/**
* WhatsApp del equipo de ventas que recibe los mensajes del micrositio.
* Formato: 10 dígitos (se agrega la lada de México +52 automáticamente) o el
* número completo con lada de país (p. ej. '5213312345678').
* 🔧 Cambiar por el número real de ventas antes de publicar.
*/
export const WHATSAPP_CONTACTO = '3312345678'
/**
* Correo de contacto mostrado en el pie. 🔧 Reemplazar por el buzón real
* (placeholder de ejemplo hasta que el cliente lo confirme).
*/
export const CORREO_CONTACTO = 'contacto@frasadesarrollos.mx'
/**
* Normaliza un teléfono a dígitos para enlaces wa.me/tel:. Si el número no trae
* lada internacional (10 dígitos), asume México (+52); si ya la trae, la respeta.
*/
export function telefonoParaEnlace(telefono: string): string {
const digitos = telefono.replace(/\D/g, '')
if (digitos.length === 10) return `52${digitos}`
return digitos
}
/** Mensaje prellenado del prospecto: "Hola, me interesa <desarrollo>…" */
export function mensajeInteres(nombreDesarrollo: string): string {
return `Hola, me interesa ${nombreDesarrollo}. ¿Me pueden enviar precios, disponibilidad y modelos?`
}
/** URL de WhatsApp con un mensaje libre prellenado */
export function urlWhatsAppConsulta(
mensaje: string,
telefono: string = WHATSAPP_CONTACTO,
): string {
const texto = encodeURIComponent(mensaje)
return `https://wa.me/${telefonoParaEnlace(telefono)}?text=${texto}`
}
/** URL de WhatsApp con el mensaje de interés prellenado */
export function urlWhatsAppDesarrollo(
nombreDesarrollo: string,
telefono: string = WHATSAPP_CONTACTO,
): string {
return urlWhatsAppConsulta(mensajeInteres(nombreDesarrollo), telefono)
}
/** Enlace mailto con el mismo mensaje de interés */
export function urlCorreoDesarrollo(
nombreDesarrollo: string,
correo: string = CORREO_CONTACTO,
): string {
const asunto = encodeURIComponent(`Información de ${nombreDesarrollo}`)
const cuerpo = encodeURIComponent(mensajeInteres(nombreDesarrollo))
return `mailto:${correo}?subject=${asunto}&body=${cuerpo}`
}
/** Teléfono en formato legible para mostrar: "33 1234 5678" */
export function telefonoLegible(telefono: string): string {
const digitos = telefonoPrefijoLocal(telefono)
if (digitos.length === 10) return `${digitos.slice(0, 2)} ${digitos.slice(2, 6)} ${digitos.slice(6)}`
return digitos
}
function telefonoPrefijoLocal(telefono: string): string {
const digitos = telefono.replace(/\D/g, '')
if (digitos.length === 12 && digitos.startsWith('52')) return digitos.slice(2)
if (digitos.length === 13 && digitos.startsWith('521')) return digitos.slice(3)
return digitos
}

View File

@@ -0,0 +1,91 @@
// Formateo compartido por el micrositio público (es-MX, moneda MXN).
const MESES = [
'Enero',
'Febrero',
'Marzo',
'Abril',
'Mayo',
'Junio',
'Julio',
'Agosto',
'Septiembre',
'Octubre',
'Noviembre',
'Diciembre',
]
const formatoMonedaMXN = new Intl.NumberFormat('es-MX', {
style: 'currency',
currency: 'MXN',
maximumFractionDigits: 0,
})
/** "$2,500,000" o null cuando no hay precio */
export function moneda(valor: number | null | undefined): string | null {
if (valor === null || valor === undefined || !Number.isFinite(valor)) return null
return formatoMonedaMXN.format(valor)
}
/** Igual que `moneda` pero con guión cuando falta el dato (para tablas) */
export function monedaOTexto(valor: number | null | undefined, reemplazo = '—'): string {
return moneda(valor) ?? reemplazo
}
/** Superficie en metros cuadrados: "85 m²" */
export function superficie(valor: number | null | undefined): string | null {
if (valor === null || valor === undefined || !Number.isFinite(valor)) return null
const numero = new Intl.NumberFormat('es-MX', { maximumFractionDigits: 2 }).format(valor)
return `${numero} m²`
}
/** "2026-09-30" o ISO completo → "Septiembre 2026" (null si no se puede leer) */
export function mesAnio(fecha: string | null | undefined): string | null {
if (!fecha) return null
const coincidencia = /^(\d{4})-(\d{2})/.exec(fecha.trim())
let anio: number
let mes: number
if (coincidencia) {
anio = Number(coincidencia[1])
mes = Number(coincidencia[2])
} else {
const interpretada = new Date(fecha)
if (Number.isNaN(interpretada.getTime())) return null
anio = interpretada.getFullYear()
mes = interpretada.getMonth() + 1
}
if (mes < 1 || mes > 12) return null
return `${MESES[mes - 1]} ${anio}`
}
/** "Preventa" | "Disponible" | "Cerrado" */
export function etiquetaEstado(estado: 'ACTIVO' | 'PREVENTA' | 'CERRADO'): string {
if (estado === 'PREVENTA') return 'Preventa'
if (estado === 'CERRADO') return 'Cerrado'
return 'Disponible'
}
/** Sin acentos y en minúsculas: búsquedas tolerantes ("Metropolitano" ≈ "metropólitano") */
export function normalizarTexto(texto: string): string {
return texto
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim()
}
/** "Alberca, Gimnasio, , Coworking" → ['Alberca', 'Gimnasio', 'Coworking'] */
export function separarAmenidades(amenidades: string | null | undefined): string[] {
if (!amenidades) return []
return amenidades
.split(',')
.map((a) => a.trim())
.filter((a) => a.length > 0)
}
/** Texto plano para meta descripción: sin saltos de línea ni espacios repetidos */
export function resumenPlano(texto: string | null | undefined, maximo = 155): string {
const plano = (texto ?? '').replace(/\s+/g, ' ').trim()
if (plano.length <= maximo) return plano
return `${plano.slice(0, maximo - 1).trimEnd()}…`
}

View File

@@ -8,8 +8,8 @@ const porId = (id: string): Modulo => {
}
describe('MODULOS', () => {
it('expone los 10 módulos del ecosistema', () => {
expect(MODULOS).toHaveLength(10)
it('expone los 11 módulos del ecosistema', () => {
expect(MODULOS).toHaveLength(11)
expect(MODULOS.map((m) => m.id)).toEqual([
'cotizador',
'alta-clientes',
@@ -21,6 +21,7 @@ describe('MODULOS', () => {
'captura',
'clientes-maestro',
'soporte',
'desarrollos',
])
})

View File

@@ -78,6 +78,13 @@ export const MODULOS: Modulo[] = [
// Visible para todos: la clave 'soporte' se agrega a todos los roles del API
claves: ['soporte'],
},
{
id: 'desarrollos',
nombre: 'Desarrollos',
descripcion: 'Alta de desarrollos: ficha, modelos, inventario y reglas del cotizador',
// Solo el rol admin tiene la clave 'desarrollos' en la matriz del API
claves: ['desarrollos'],
},
]
/**

View File

@@ -0,0 +1,225 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { DesarrolloPublicoPage } from './DesarrolloPublicoPage'
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
const respuestaOk = (data: unknown) =>
new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } })
const DESARROLLO = {
clave: 'ATELIER',
nombre: 'Atelier Residencial',
estado: 'PREVENTA',
tipo: 'VERTICAL',
zona: 'Zapopan, Jalisco',
direccion: 'Av. Patria 1234',
descripcion: 'Departamentos de 2 y 3 recámaras con acabados de lujo.\nEntrega inmediata en preventa.',
amenidades: 'Alberca, Gimnasio, Coworking',
imagenUrl: null,
logoUrl: null,
fechaEntregaProgramada: '2026-09-30',
modelos: [
{
nombre: 'Modelo A',
recamaras: 2,
banos: 2,
estacionamientos: 1,
supConstruccion: 85,
disponibles: 2,
precioDesde: 2000000,
},
{
nombre: 'Modelo B',
recamaras: 3,
banos: 2,
estacionamientos: 2,
supConstruccion: 120,
disponibles: 1,
precioDesde: 3000000,
},
{
nombre: 'Modelo D',
recamaras: 4,
banos: 3,
estacionamientos: 2,
supConstruccion: 180,
disponibles: 0,
precioDesde: null,
},
],
unidadesDisponibles: [
{ codigo: 'A-101', modelo: 'Modelo A', precioLista: 2000000, precioAcabados: 2150000, supConstruccion: 85, nivel: '1' },
{ codigo: 'A-102', modelo: 'Modelo A', precioLista: 2500000, precioAcabados: null, supConstruccion: 85, nivel: '1' },
{ codigo: 'B-201', modelo: 'Modelo B', precioLista: 3000000, precioAcabados: 3200000, supConstruccion: 120, nivel: '2' },
{ codigo: 'C-301', modelo: 'Modelo C', precioLista: 6000000, precioAcabados: 6300000, supConstruccion: 150, nivel: '3' },
{ codigo: 'C-302', modelo: 'Modelo C', precioLista: 6500000, precioAcabados: null, supConstruccion: 150, nivel: '3' },
{ codigo: 'C-303', modelo: 'Modelo C', precioLista: 7000000, precioAcabados: 7400000, supConstruccion: 150, nivel: '4' },
],
}
let fetchMock: ReturnType<typeof vi.fn<FetchFn>>
function montar() {
return render(<DesarrolloPublicoPage clave="ATELIER" />)
}
/** Códigos de las filas visibles, en el orden en que se pintan */
function codigosVisibles(): string[] {
return screen.queryAllByTestId('codigo-unidad').map((el) => el.textContent ?? '')
}
beforeEach(() => {
fetchMock = vi.fn<FetchFn>()
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
document.title = ''
})
describe('DesarrolloPublicoPage', () => {
beforeEach(() => {
fetchMock.mockImplementation((input) => {
const url = String(input)
if (url.includes('/publico/desarrollos/ATELIER')) return Promise.resolve(respuestaOk(DESARROLLO))
return Promise.reject(new Error(`fetch inesperado: ${url}`))
})
})
it('carga la ficha y muestra hero, datos clave, modelos y disponibilidad', async () => {
montar()
const h1 = await screen.findByRole('heading', { level: 1 })
expect(h1.textContent).toBe('Atelier Residencial')
// Estructura semántica: un solo h1 y las regiones principales
expect(screen.getAllByRole('heading', { level: 1 })).toHaveLength(1)
expect(document.querySelectorAll('header')).toHaveLength(1)
expect(document.querySelectorAll('main')).toHaveLength(1)
expect(document.querySelectorAll('footer')).toHaveLength(1)
expect(screen.getByText('Preventa')).toBeTruthy()
expect(screen.getByText('Entrega: Septiembre 2026')).toBeTruthy()
expect(screen.getAllByText(/Zapopan, Jalisco/).length).toBeGreaterThan(0)
// Datos clave
expect(screen.getByText('Unidades disponibles')).toBeTruthy()
expect(screen.getByText('6')).toBeTruthy()
expect(screen.getByText('Zona')).toBeTruthy()
// Amenidades como chips
const descripcion = screen.getByRole('region', { name: 'Sobre el desarrollo' })
expect(within(descripcion).getByText('Alberca')).toBeTruthy()
expect(within(descripcion).getByText('Coworking')).toBeTruthy()
// Modelos: 3 tarjetas, la agotada marcada
const modelos = screen.getByRole('region', { name: 'Modelos disponibles' })
expect(within(modelos).getByText('Modelo A')).toBeTruthy()
expect(within(modelos).getByText('Modelo D')).toBeTruthy()
expect(within(modelos).getByText('Agotado')).toBeTruthy()
expect(within(modelos).getByText('$2,000,000')).toBeTruthy()
expect(within(modelos).getByText('Precio a consultar')).toBeTruthy()
// Disponibilidad: las 6 unidades con precios
const disponibilidad = screen.getByRole('region', { name: 'Disponibilidad' })
expect(within(disponibilidad).getByText('6 unidades')).toBeTruthy()
expect(codigosVisibles()).toEqual(['A-101', 'A-102', 'B-201', 'C-301', 'C-302', 'C-303'])
expect(screen.getByText('$2,150,000')).toBeTruthy()
expect(screen.getAllByText('85 m²').length).toBeGreaterThan(0)
})
it('arma el CTA de WhatsApp con el mensaje del desarrollo y el título SEO', async () => {
montar()
await screen.findByRole('heading', { level: 1 })
const enlace = screen.getByRole('link', { name: /Escríbenos por WhatsApp/ })
expect(enlace.getAttribute('href')).toContain('https://wa.me/')
expect(decodeURIComponent(enlace.getAttribute('href') ?? '')).toContain(
'Hola, me interesa Atelier Residencial',
)
expect(document.title).toContain('Atelier Residencial')
expect(
document.querySelector('meta[name="description"]')?.getAttribute('content'),
).toContain('Departamentos de 2 y 3 recámaras')
})
it('filtra por modelo, por rango de precio y por código', async () => {
montar()
await screen.findByRole('heading', { level: 1 })
fireEvent.change(screen.getByLabelText('Filtrar por modelo'), { target: { value: 'Modelo A' } })
expect(codigosVisibles()).toEqual(['A-101', 'A-102'])
expect(screen.getByText('2 unidades de 6')).toBeTruthy()
fireEvent.change(screen.getByLabelText('Filtrar por modelo'), { target: { value: '' } })
// Rango más económico de los tres derivados del inventario (2M, 2.5M, 3M)
fireEvent.change(screen.getByLabelText('Rango de precio'), { target: { value: 'r0' } })
expect(codigosVisibles()).toEqual(['A-101', 'A-102', 'B-201'])
// El rango intermedio no tiene inventario: se muestra el estado vacío
fireEvent.change(screen.getByLabelText('Rango de precio'), { target: { value: 'r1' } })
expect(codigosVisibles()).toEqual([])
expect(screen.getByText('No hay unidades que coincidan con tu búsqueda.')).toBeTruthy()
fireEvent.click(screen.getAllByRole('button', { name: 'Limpiar filtros' })[0])
expect(codigosVisibles()).toHaveLength(6)
fireEvent.change(screen.getByLabelText('Buscar por código'), { target: { value: 'c-3' } })
expect(codigosVisibles()).toEqual(['C-301', 'C-302', 'C-303'])
})
it('ordena el inventario por precio en ambos sentidos', async () => {
montar()
await screen.findByRole('heading', { level: 1 })
expect(codigosVisibles()[0]).toBe('A-101')
fireEvent.click(screen.getByRole('button', { name: /Precio: menor a mayor/ }))
expect(screen.getByRole('button', { name: /Precio: mayor a menor/ })).toBeTruthy()
expect(codigosVisibles()).toEqual(['C-303', 'C-302', 'C-301', 'B-201', 'A-102', 'A-101'])
})
it('pagina el inventario con "Mostrar más" cuando hay muchas unidades', async () => {
const muchas = Array.from({ length: 20 }, (_, i) => ({
codigo: `U-${String(i + 1).padStart(3, '0')}`,
modelo: 'Modelo A',
precioLista: 2000000 + i * 10000,
precioAcabados: null,
supConstruccion: 85,
nivel: '1',
}))
fetchMock.mockImplementation(() => Promise.resolve(respuestaOk({ ...DESARROLLO, unidadesDisponibles: muchas })))
montar()
await screen.findByRole('heading', { level: 1 })
expect(codigosVisibles()).toHaveLength(12)
fireEvent.click(screen.getByRole('button', { name: /Mostrar más unidades/ }))
expect(codigosVisibles()).toHaveLength(20)
expect(screen.queryByRole('button', { name: /Mostrar más unidades/ })).toBeNull()
})
it('muestra "desarrollo no encontrado" cuando el endpoint responde 404', async () => {
fetchMock.mockImplementation(() => Promise.resolve(new Response(null, { status: 404 })))
montar()
expect(await screen.findByText('Desarrollo no encontrado')).toBeTruthy()
expect(screen.queryByTestId('codigo-unidad')).toBeNull()
expect(
screen.getByRole('link', { name: /Preguntar por WhatsApp/ }).getAttribute('href'),
).toContain('https://wa.me/')
})
it('muestra un estado de error reintentable cuando el servicio falla', async () => {
fetchMock.mockImplementation(() => Promise.resolve(new Response(null, { status: 500 })))
montar()
expect(await screen.findByText('No pudimos cargar el desarrollo')).toBeTruthy()
fetchMock.mockImplementation(() => Promise.resolve(respuestaOk(DESARROLLO)))
fireEvent.click(screen.getByRole('button', { name: /Reintentar/ }))
expect(await screen.findByRole('heading', { level: 1 })).toBeTruthy()
expect(screen.getByText('Atelier Residencial')).toBeTruthy()
})
})

View File

@@ -0,0 +1,166 @@
import { useCallback, useEffect, useState } from 'react'
import { EncabezadoDesarrollo } from '../components/publico/EncabezadoDesarrollo'
import { EsqueletoDesarrollo } from '../components/publico/EsqueletoDesarrollo'
import { DesarrolloNoEncontrado, EstadoErrorDesarrollo } from '../components/publico/EstadosDesarrollo'
import { FranjaDatos } from '../components/publico/FranjaDatos'
import { PieContacto } from '../components/publico/PieContacto'
import { SeccionDescripcion } from '../components/publico/SeccionDescripcion'
import { SeccionModelos } from '../components/publico/SeccionModelos'
import { TablaDisponibilidad } from '../components/publico/TablaDisponibilidad'
import {
CORREO_CONTACTO,
WHATSAPP_CONTACTO,
urlCorreoDesarrollo,
urlWhatsAppConsulta,
urlWhatsAppDesarrollo,
} from '../components/publico/contacto'
import { mesAnio, resumenPlano } from '../components/publico/formato'
import {
ErrorDesarrolloNoEncontrado,
obtenerDesarrolloPublico,
type DesarrolloPublico,
} from '../services/publico'
interface Props {
/** Clave del desarrollo tomada de la ruta /d/:clave */
clave: string
}
type Carga =
| { estado: 'cargando' }
| { estado: 'listo'; desarrollo: DesarrolloPublico }
| { estado: 'no-encontrado' }
| { estado: 'error'; mensaje: string }
/** Escribe título y meta descripción del micrositio (SEO básico) */
function aplicarSEO(desarrollo: DesarrolloPublico) {
const zona = desarrollo.zona?.trim()
document.title = `${desarrollo.nombre}${zona ? ` en ${zona}` : ''} | FRASA Desarrollos`
const descripcion =
resumenPlano(desarrollo.descripcion) ||
`${desarrollo.nombre}: conoce modelos, precios y unidades disponibles${zona ? ` en ${zona}` : ''}.`
let meta = document.querySelector('meta[name="description"]')
if (!meta) {
meta = document.createElement('meta')
meta.setAttribute('name', 'description')
document.head.appendChild(meta)
}
meta.setAttribute('content', descripcion)
}
/**
* Micrositio público de un desarrollo (sin sesión): hero, descripción, modelos y
* disponibilidad. Se monta en la ruta /d/:clave pasando la clave como prop.
*/
export function DesarrolloPublicoPage({ clave }: Props) {
const [carga, setCarga] = useState<Carga>({ estado: 'cargando' })
const [intento, setIntento] = useState(0)
useEffect(() => {
let cancelado = false
setCarga({ estado: 'cargando' })
obtenerDesarrolloPublico(clave)
.then((desarrollo) => {
if (!cancelado) setCarga({ estado: 'listo', desarrollo })
})
.catch((error: unknown) => {
if (cancelado) return
if (error instanceof ErrorDesarrolloNoEncontrado) {
setCarga({ estado: 'no-encontrado' })
return
}
setCarga({
estado: 'error',
mensaje:
error instanceof Error && error.message
? error.message
: 'Intenta de nuevo en unos momentos.',
})
})
return () => {
cancelado = true
}
}, [clave, intento])
useEffect(() => {
if (carga.estado === 'listo') aplicarSEO(carga.desarrollo)
}, [carga])
const irADisponibilidad = useCallback(() => {
const seccion = document.getElementById('disponibilidad')
if (seccion && typeof seccion.scrollIntoView === 'function') {
seccion.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}, [])
if (carga.estado === 'cargando') return <EsqueletoDesarrollo />
if (carga.estado === 'no-encontrado') {
return (
<DesarrolloNoEncontrado
urlWhatsApp={urlWhatsAppConsulta(
'Hola, quiero información de los desarrollos disponibles de FRASA.',
)}
/>
)
}
if (carga.estado === 'error') {
return (
<EstadoErrorDesarrollo
mensaje={carga.mensaje}
onReintentar={() => setIntento((valor) => valor + 1)}
/>
)
}
const desarrollo = carga.desarrollo
const nombre = desarrollo.nombre
const urlWhatsApp = urlWhatsAppDesarrollo(nombre)
const urlCorreo = urlCorreoDesarrollo(nombre)
return (
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
<EncabezadoDesarrollo
nombre={nombre}
zona={desarrollo.zona}
direccion={desarrollo.direccion}
estado={desarrollo.estado}
tipo={desarrollo.tipo}
fechaEntregaProgramada={desarrollo.fechaEntregaProgramada}
imagenUrl={desarrollo.imagenUrl}
logoUrl={desarrollo.logoUrl}
urlWhatsApp={urlWhatsApp}
onVerDisponibilidad={irADisponibilidad}
/>
<main>
<FranjaDatos
unidades={desarrollo.unidadesDisponibles.length}
modelos={desarrollo.modelos.length}
entrega={mesAnio(desarrollo.fechaEntregaProgramada) ?? 'Por definir'}
zona={desarrollo.zona?.trim() || 'Por definir'}
/>
<SeccionDescripcion
descripcion={desarrollo.descripcion}
amenidades={desarrollo.amenidades}
/>
<SeccionModelos modelos={desarrollo.modelos} onVerUnidades={irADisponibilidad} />
<TablaDisponibilidad unidades={desarrollo.unidadesDisponibles} urlWhatsApp={urlWhatsApp} />
</main>
<PieContacto
nombreDesarrollo={nombre}
urlWhatsApp={urlWhatsApp}
urlCorreo={urlCorreo}
telefono={WHATSAPP_CONTACTO}
correo={CORREO_CONTACTO}
/>
</div>
)
}

View File

@@ -0,0 +1,357 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { DesarrollosPage } from './DesarrollosPage'
import { REGLAS_POR_DEFECTO } from '../components/desarrollos/reglas'
import type { DesarrolloResumen } from '../services/desarrollos'
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
function crearStorageStub(): Storage {
const mapa = new Map<string, string>()
return {
getItem: (k: string) => mapa.get(k) ?? null,
setItem: (k: string, v: string) => void mapa.set(k, v),
removeItem: (k: string) => void mapa.delete(k),
clear: () => mapa.clear(),
key: () => null,
get length() {
return mapa.size
},
} as Storage
}
const respuestaOk = (data: unknown) =>
new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } })
const respuestaError = (status: number, mensaje: string) =>
new Response(JSON.stringify({ error: mensaje }), {
status,
headers: { 'Content-Type': 'application/json' },
})
const RESUMEN: DesarrolloResumen = {
id: 'd1',
clave: 'SENDA-401',
nombre: 'Senda Residencial',
estado: 'PREVENTA',
tipo: 'HORIZONTAL',
activoEnCotizador: true,
activoEnAlta: false,
modelos: [{ id: 'm1', nombre: '2RA', activo: true }],
unidadesTotales: 12,
unidadesDisponibles: 7,
tieneReglas: false,
}
/** Inventario tal como lo devuelve GET /api/unidades?desarrollo=CLAVE */
const UNIDADES = [
{
codigo: 'SENDA-401-1',
modelo: '2RA',
status: 'DISPONIBLE',
precioSinAcabados: 1500000,
precioConAcabados: 1650000,
supConstruccion: 120,
fechaEntrega: '2027-03-01',
},
{
codigo: 'SENDA-401-2',
modelo: '2RA',
status: 'VENDIDO',
precioSinAcabados: 1600000,
precioConAcabados: 1750000,
supConstruccion: 130,
},
]
/** Ficha tal como la devuelve GET /api/desarrollos/:id (reglas envueltas) */
const FICHA_API = {
...RESUMEN,
zona: 'Zona Norte',
direccion: 'Av. Reforma 123',
constructora: 'Constructora Frasa',
descripcion: 'Casas de 2 y 3 recámaras.',
amenidades: 'Alberca, gimnasio',
fechaInicioObra: '2026-01-15',
fechaEntregaProgramada: '2027-03-01',
fechaEntregaReprogramada: null,
prefijoUnidadPdf: 'CASA',
imagenUrl: null,
logoUrl: null,
unidadesPorEstatus: { DISPONIBLE: 7, VENDIDO: 5 },
reglas: null,
}
const MENSAJE_409_BORRADO = 'No se puede eliminar: el desarrollo tiene unidades y ventas asociadas.'
let fetchMock: ReturnType<typeof vi.fn<FetchFn>>
let lista: DesarrolloResumen[]
let fichaApi: typeof FICHA_API
beforeEach(() => {
lista = [RESUMEN]
fichaApi = FICHA_API
fetchMock = vi.fn<FetchFn>()
vi.stubGlobal('fetch', fetchMock)
vi.stubGlobal('localStorage', crearStorageStub())
vi.stubGlobal('confirm', () => true)
localStorage.setItem(
'frasa_auth',
JSON.stringify({ token: 'jwt-prueba', usuario: { nombre: 'Prueba', rol: 'admin', asignacion: null } }),
)
fetchMock.mockImplementation((input, init) => {
const url = String(input)
const metodo = init?.method ?? 'GET'
if (url.includes('/unidades?desarrollo=')) return Promise.resolve(respuestaOk(UNIDADES))
if (url.endsWith('/desarrollos/reglas-cotizador')) {
return Promise.resolve(respuestaOk({ reglasPorDesarrollo: { 'SENDA-401': REGLAS_POR_DEFECTO } }))
}
if (url.endsWith('/desarrollos') && metodo === 'GET') return Promise.resolve(respuestaOk({ desarrollos: lista }))
if (url.endsWith('/desarrollos') && metodo === 'POST') {
const cuerpo = JSON.parse(String(init?.body ?? '{}')) as { clave: string }
const creado = { ...fichaApi, id: 'd2', clave: cuerpo.clave, nombre: 'Senda Residencial 2' }
lista = [...lista, { ...creado }]
return Promise.resolve(respuestaOk(creado))
}
if (url.endsWith('/desarrollos/d1') && metodo === 'GET') return Promise.resolve(respuestaOk(fichaApi))
if (url.endsWith('/desarrollos/d1/reglas') && metodo === 'PUT') {
const parametros = JSON.parse(String(init?.body ?? '{}')) as unknown
return Promise.resolve(respuestaOk({ desarrolloId: 'd1', parametros, updatedAt: null, updatedPorId: null }))
}
if (url.endsWith('/desarrollos/d1/unidades') && metodo === 'POST') {
const cuerpo = JSON.parse(String(init?.body ?? '{}')) as { generar?: { desde: number; hasta: number } }
const creadas = cuerpo.generar ? cuerpo.generar.hasta - cuerpo.generar.desde + 1 : 0
return Promise.resolve(respuestaOk({ creadas, unidades: [] }))
}
if (url.endsWith('/desarrollos/d1') && metodo === 'DELETE') {
return Promise.resolve(respuestaError(409, MENSAJE_409_BORRADO))
}
return Promise.resolve(respuestaOk({}))
})
})
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})
describe('DesarrollosPage', () => {
it('renderiza el listado de desarrollos en tarjetas', async () => {
render(<DesarrollosPage onVolver={() => {}} />)
expect(screen.getByText('Desarrollos')).toBeTruthy()
expect(await screen.findByText('Senda Residencial')).toBeTruthy()
expect(screen.getByText(/SENDA-401/)).toBeTruthy()
expect(screen.getAllByText('Preventa').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('2RA')).toBeTruthy()
expect(screen.getByText('En cotizador')).toBeTruthy()
expect(screen.getByText('Oculto en altas')).toBeTruthy()
expect(screen.getByText('Sin reglas del cotizador')).toBeTruthy()
expect(screen.getAllByText('12').length).toBeGreaterThanOrEqual(1)
expect(screen.getAllByText('7').length).toBeGreaterThanOrEqual(1)
expect(screen.getByText('1 de 1 desarrollos')).toBeTruthy()
})
it('busca por nombre o clave y avisa cuando no hay coincidencias', async () => {
render(<DesarrollosPage onVolver={() => {}} />)
expect(await screen.findByText('Senda Residencial')).toBeTruthy()
const buscador = screen.getByLabelText(/^Buscar desarrollo/i)
fireEvent.change(buscador, { target: { value: 'senda' } })
expect(screen.getByText('Senda Residencial')).toBeTruthy()
fireEvent.change(buscador, { target: { value: 'noexiste' } })
expect(screen.getByText('No se encontraron desarrollos con ese filtro.')).toBeTruthy()
})
it('muestra el estado vacío cuando aún no hay desarrollos', async () => {
lista = []
render(<DesarrollosPage onVolver={() => {}} />)
expect(await screen.findByText('Aún no hay desarrollos')).toBeTruthy()
})
it('abre el alta y valida una clave inválida sin llamar al API', async () => {
render(<DesarrollosPage onVolver={() => {}} />)
expect(await screen.findByText('Senda Residencial')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '+ Nuevo desarrollo' }))
expect(screen.getByRole('dialog')).toBeTruthy()
expect(screen.getByText('Nuevo desarrollo')).toBeTruthy()
const clave = screen.getByLabelText(/^Clave/) as HTMLInputElement
fireEvent.change(clave, { target: { value: 'senda 401' } })
// El campo normaliza a mayúsculas, pero el espacio sigue siendo inválido
expect(clave.value).toBe('SENDA 401')
fireEvent.change(screen.getByLabelText(/^Nombre/), { target: { value: 'Senda Residencial 2' } })
expect(screen.getByText(/La clave debe tener de 2 a 20 caracteres/)).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Crear desarrollo' }))
const huboPost = fetchMock.mock.calls.some(
([url, init]) => String(url).endsWith('/desarrollos') && init?.method === 'POST',
)
expect(huboPost).toBe(false)
expect(screen.getByText(/La clave debe tener de 2 a 20 caracteres/)).toBeTruthy()
})
it('guarda un desarrollo nuevo y refresca el listado', async () => {
render(<DesarrollosPage onVolver={() => {}} />)
expect(await screen.findByText('Senda Residencial')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '+ Nuevo desarrollo' }))
fireEvent.change(screen.getByLabelText(/^Clave/), { target: { value: 'SENDA-402' } })
fireEvent.change(screen.getByLabelText(/^Nombre/), { target: { value: 'Senda Residencial 2' } })
fireEvent.click(screen.getByRole('button', { name: 'Crear desarrollo' }))
expect(await screen.findByText('Desarrollo "Senda Residencial 2" guardado.')).toBeTruthy()
const llamada = fetchMock.mock.calls.find(
([url, init]) => String(url).endsWith('/desarrollos') && init?.method === 'POST',
)
expect(llamada).toBeTruthy()
const cuerpo = JSON.parse(String(llamada?.[1]?.body)) as { clave: string; estado: string }
expect(cuerpo.clave).toBe('SENDA-402')
expect(cuerpo.estado).toBeTruthy()
})
it('muestra el motivo del 409 al intentar borrar el desarrollo', async () => {
render(<DesarrollosPage onVolver={() => {}} />)
fireEvent.click(await screen.findByRole('button', { name: 'Abrir ficha de Senda Residencial' }))
expect(await screen.findByRole('tab', { name: 'Datos' })).toBeTruthy()
expect(screen.getByRole('heading', { name: 'Senda Residencial' })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Eliminar desarrollo' }))
expect(await screen.findByText(MENSAJE_409_BORRADO)).toBeTruthy()
expect(screen.getByText(/cambia su estado a CERRADO/)).toBeTruthy()
})
it('la vista previa del generador calcula los códigos y el total', async () => {
render(<DesarrollosPage onVolver={() => {}} />)
fireEvent.click(await screen.findByRole('button', { name: 'Abrir ficha de Senda Residencial' }))
fireEvent.click(await screen.findByRole('tab', { name: 'Inventario' }))
expect(screen.getByText('Completa patrón y rango para ver los códigos que se crearán.')).toBeTruthy()
fireEvent.change(screen.getByLabelText(/^Patrón de código/), {
target: { value: 'SENDA-401-{n}' },
})
fireEvent.change(screen.getByLabelText(/^Desde/), { target: { value: '1' } })
fireEvent.change(screen.getByLabelText(/^Hasta/), { target: { value: '7' } })
expect(screen.getByText('Se crearán 7 unidades.')).toBeTruthy()
expect(screen.getByText('SENDA-401-1')).toBeTruthy()
expect(screen.getByText('SENDA-401-5')).toBeTruthy()
expect(screen.getByText('… y 2 más')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Crear 7 unidades' })).toBeTruthy()
})
it('el generador sustituye {clave} por la clave del desarrollo', async () => {
render(<DesarrollosPage onVolver={() => {}} />)
fireEvent.click(await screen.findByRole('button', { name: 'Abrir ficha de Senda Residencial' }))
fireEvent.click(await screen.findByRole('tab', { name: 'Inventario' }))
fireEvent.change(screen.getByLabelText(/^Patrón de código/), { target: { value: '{clave}-{n}' } })
fireEvent.change(screen.getByLabelText(/^Desde/), { target: { value: '10' } })
fireEvent.change(screen.getByLabelText(/^Hasta/), { target: { value: '12' } })
expect(screen.getByText('Se crearán 3 unidades.')).toBeTruthy()
expect(screen.getByText('SENDA-401-10')).toBeTruthy()
expect(screen.getByText('SENDA-401-12')).toBeTruthy()
})
it('muestra el inventario existente con su conteo por estatus', async () => {
render(<DesarrollosPage onVolver={() => {}} />)
fireEvent.click(await screen.findByRole('button', { name: 'Abrir ficha de Senda Residencial' }))
fireEvent.click(await screen.findByRole('tab', { name: 'Inventario' }))
expect(await screen.findByText('SENDA-401-1')).toBeTruthy()
expect(screen.getByText('SENDA-401-2')).toBeTruthy()
// Conteo por estatus que viene en la ficha (también aparece en cada unidad)
expect(screen.getAllByText('DISPONIBLE').length).toBeGreaterThanOrEqual(1)
expect(screen.getAllByText('VENDIDO').length).toBeGreaterThanOrEqual(1)
})
it('precarga las reglas vigentes y muestra el resumen legible', async () => {
render(<DesarrollosPage onVolver={() => {}} />)
fireEvent.click(await screen.findByRole('button', { name: 'Abrir ficha de Senda Residencial' }))
fireEvent.click(await screen.findByRole('tab', { name: 'Reglas del cotizador' }))
expect(await screen.findByText('Parámetros generales')).toBeTruthy()
// El desarrollo no tiene reglas guardadas: se precargan las vigentes del cotizador
expect(screen.getByText(/no tiene reglas guardadas/)).toBeTruthy()
expect(screen.getByRole('heading', { name: 'Crédito Bancario' })).toBeTruthy()
const nDefault = screen.getByLabelText(/^N por defecto/) as HTMLInputElement
await waitFor(() => expect(nDefault.value).toBe('12'))
expect(screen.getAllByText(/Enganche 2\.5% del precio de lista/).length).toBeGreaterThanOrEqual(1)
fireEvent.change(screen.getAllByLabelText(/^Enganche \(%\)/)[0]!, { target: { value: '10' } })
expect(screen.getAllByText(/Enganche 10% del precio de lista/).length).toBeGreaterThanOrEqual(1)
// Las 4 propuestas vigentes quedan activas
const activas = screen.getAllByLabelText(/^Propuesta activa/) as HTMLInputElement[]
expect(activas.every((c) => c.checked)).toBe(true)
})
it('el generador envía el rango con el precio de lista al API', async () => {
render(<DesarrollosPage onVolver={() => {}} />)
fireEvent.click(await screen.findByRole('button', { name: 'Abrir ficha de Senda Residencial' }))
fireEvent.click(await screen.findByRole('tab', { name: 'Inventario' }))
fireEvent.change(screen.getByLabelText(/^Patrón de código/), { target: { value: 'SENDA-401-{n}' } })
fireEvent.change(screen.getByLabelText(/^Desde/), { target: { value: '1' } })
fireEvent.change(screen.getByLabelText(/^Hasta/), { target: { value: '7' } })
fireEvent.change(screen.getByLabelText(/^Precio de lista/), { target: { value: '1500000' } })
fireEvent.click(screen.getByRole('button', { name: 'Crear 7 unidades' }))
expect(await screen.findByText('Se crearon 7 unidades en el inventario.')).toBeTruthy()
const llamada = fetchMock.mock.calls.find(
([url, init]) => String(url).endsWith('/desarrollos/d1/unidades') && init?.method === 'POST',
)
expect(llamada).toBeTruthy()
// El API valida el cuerpo con .strict(): exactamente { generar: {...} }
expect(JSON.parse(String(llamada?.[1]?.body))).toEqual({
generar: { patron: 'SENDA-401-{n}', desde: 1, hasta: 7, precioLista: 1500000 },
})
})
it('guardar reglas envía fracciones y el prefijo del PDF', async () => {
render(<DesarrollosPage onVolver={() => {}} />)
fireEvent.click(await screen.findByRole('button', { name: 'Abrir ficha de Senda Residencial' }))
fireEvent.click(await screen.findByRole('tab', { name: 'Reglas del cotizador' }))
const boton = await screen.findByRole('button', { name: 'Guardar reglas' })
await waitFor(() =>
expect((screen.getAllByLabelText(/^Propuesta activa/)[0] as HTMLInputElement).checked).toBe(true),
)
fireEvent.click(boton)
expect(
await screen.findByText('Reglas guardadas. El cotizador las usará en las próximas propuestas.'),
).toBeTruthy()
const llamada = fetchMock.mock.calls.find(
([url, init]) => String(url).endsWith('/desarrollos/d1/reglas') && init?.method === 'PUT',
)
expect(llamada).toBeTruthy()
const cuerpo = JSON.parse(String(llamada?.[1]?.body)) as {
prefijoUnidadPdf: string
nDefault: number
propuestas: { tipo: string; pctEnganche: number; pctPagoFinal: number; modoMensualidades: string }[]
}
expect(cuerpo.prefijoUnidadPdf).toBe('CASA')
expect(cuerpo.nDefault).toBe(12)
expect(cuerpo.propuestas).toHaveLength(4)
const bancario = cuerpo.propuestas.find((p) => p.tipo === 'CREDITO_BANCARIO')!
// Los porcentajes viajan como fracción (0.025 = 2.5%), igual que en el cotizador
expect(bancario.pctEnganche).toBe(0.025)
expect(bancario.pctPagoFinal).toBe(0.9)
expect(bancario.modoMensualidades).toBe('remanente')
})
it('vuelve al listado con el botón Volver', async () => {
const volver = vi.fn()
render(<DesarrollosPage onVolver={volver} />)
expect(await screen.findByText('Senda Residencial')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '← Volver' }))
expect(volver).toHaveBeenCalledOnce()
})
})

View File

@@ -0,0 +1,135 @@
import { useCallback, useEffect, useState } from 'react'
import {
listarDesarrollos,
obtenerDesarrollo,
type DesarrolloFicha,
type DesarrolloResumen,
} from '../services/desarrollos'
import { ListaDesarrollos } from '../components/desarrollos/ListaDesarrollos'
import { ModalDesarrollo } from '../components/desarrollos/ModalDesarrollo'
import { FichaDesarrollo } from '../components/desarrollos/FichaDesarrollo'
import { MensajeError, MensajeExito } from '../components/desarrollos/comun'
interface Props {
onVolver: () => void
}
/**
* Página del módulo Desarrollos (solo administrador): listado en tarjetas,
* alta/edición y ficha con modelos, inventario y reglas del cotizador.
*/
export function DesarrollosPage({ onVolver }: Props) {
const [desarrollos, setDesarrollos] = useState<DesarrolloResumen[]>([])
const [cargando, setCargando] = useState(true)
const [error, setError] = useState('')
const [aviso, setAviso] = useState('')
const [fichaId, setFichaId] = useState<string | null>(null)
const [modalAbierto, setModalAbierto] = useState(false)
const [enEdicion, setEnEdicion] = useState<DesarrolloFicha | null>(null)
const cargar = useCallback(async () => {
setCargando(true)
setError('')
try {
const data = await listarDesarrollos()
setDesarrollos(data.desarrollos ?? [])
} catch (err) {
setError(
err instanceof Error && err.message
? err.message
: 'No se pudieron cargar los desarrollos.',
)
} finally {
setCargando(false)
}
}, [])
useEffect(() => {
void cargar()
}, [cargar])
const abrirNuevo = () => {
setEnEdicion(null)
setAviso('')
setModalAbierto(true)
}
const abrirEdicion = async (desarrollo: DesarrolloResumen) => {
setAviso('')
try {
setEnEdicion(await obtenerDesarrollo(desarrollo.id))
setModalAbierto(true)
} catch (err) {
setError(
err instanceof Error && err.message
? err.message
: 'No se pudo abrir el desarrollo para editar.',
)
}
}
const alGuardar = async (ficha: DesarrolloFicha) => {
setModalAbierto(false)
setEnEdicion(null)
setAviso(`Desarrollo "${ficha.nombre}" guardado.`)
await cargar()
}
return (
<main className="w-full flex-1 px-4 py-12 sm:px-6 lg:px-8 sm:py-16">
<div className="flex items-start gap-4">
<button
type="button"
onClick={onVolver}
className="rounded-lg border border-[#D1D5DB] bg-white px-4 py-2 text-sm font-medium text-[#374151] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB] dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#CBD5E1] dark:hover:bg-[#334155]"
>
← Volver
</button>
<div className="min-w-0 flex-1">
<h1 className="text-2xl font-semibold tracking-tight text-[#111827] dark:text-[#E2E8F0] sm:text-3xl">
Desarrollos
</h1>
<p className="mt-1 text-sm text-[#64748B] dark:text-[#94A3B8]">
Altas, modelos, inventario y reglas del cotizador de cada desarrollo.
</p>
</div>
</div>
<div className="mt-6 space-y-4">
{error && <MensajeError>{error}</MensajeError>}
{aviso && !fichaId && <MensajeExito>{aviso}</MensajeExito>}
{fichaId ? (
<FichaDesarrollo
desarrolloId={fichaId}
onVolver={() => setFichaId(null)}
onCambio={() => void cargar()}
/>
) : (
<ListaDesarrollos
desarrollos={desarrollos}
cargando={cargando}
onNuevo={abrirNuevo}
onAbrir={(desarrollo) => {
setAviso('')
setError('')
setFichaId(desarrollo.id)
}}
onEditar={(desarrollo) => void abrirEdicion(desarrollo)}
/>
)}
</div>
{modalAbierto && (
<ModalDesarrollo
desarrollo={enEdicion}
onCerrar={() => {
setModalAbierto(false)
setEnEdicion(null)
}}
onGuardado={(ficha) => void alGuardar(ficha)}
/>
)}
</main>
)
}

View File

@@ -0,0 +1,384 @@
import { API_URL, conAuth, manejar401 } from './auth'
// ─── Tipos ──────────────────────────────────────────────────────────
export type EstadoDesarrollo = 'ACTIVO' | 'PREVENTA' | 'CERRADO'
export type TipoDesarrollo = 'VERTICAL' | 'HORIZONTAL'
export type FuenteDeN = 'fechaEntrega' | 'fijo' | 'porUnidad'
export type BasePrecio = 'lista' | 'final'
export type ModoMensualidades = 'remanente' | 'remanenteLista' | 'porcentaje' | 'ninguna'
/** Estatus del inventario (los mismos valores que maneja el cotizador) */
export type EstatusUnidad =
| 'DISPONIBLE'
| 'APARTADO'
| 'APARTADA'
| 'VENDIDO'
| 'VENDIDA'
| 'ESCRITURADO'
| 'BLOQUEADO'
export interface ModeloDesarrollo {
id: string
nombre: string
recamaras?: number | null
banos?: number | null
estacionamientos?: number | null
supConstruccion?: number | null
activo: boolean
}
export interface PropuestaReglas {
tipo: string
claveDescuento: string
titulo: string
descuentoDefault: number
pctEnganche: number
engancheBase: BasePrecio
pctPagoFinal: number
pagoFinalBase: BasePrecio
modoMensualidades: ModoMensualidades
/** Infonavit (SOLANA): con enganche 0 no se descuenta el apartado */
engancheCeroSinApartado?: boolean
}
export interface ReglasCotizador {
precioUnico: boolean
fuenteDeN: FuenteDeN
/** N fijo cuando fuenteDeN === 'fijo' (entrega inmediata / terminación de obra) */
nFijo?: number
nDefault: number
cuotaMantenimiento: number
prefijoUnidadPdf: string
propuestas: PropuestaReglas[]
}
export interface DesarrolloResumen {
id: string
clave: string
nombre: string
estado: EstadoDesarrollo
tipo: TipoDesarrollo
activoEnCotizador: boolean
activoEnAlta: boolean
modelos: ModeloDesarrollo[]
unidadesTotales: number
unidadesDisponibles: number
tieneReglas: boolean
}
export interface DesarrolloFicha extends DesarrolloResumen {
constructora?: string | null
direccion?: string | null
zona?: string | null
descripcion?: string | null
amenidades?: string | null
fechaInicioObra?: string | null
fechaEntregaProgramada?: string | null
fechaEntregaReprogramada?: string | null
prefijoUnidadPdf?: string | null
imagenUrl?: string | null
logoUrl?: string | null
/** Conteo del inventario por estatus (clave tal como la guarda la tabla Unidad) */
conteosPorEstatus: Record<string, number>
/** Reglas del cotizador guardadas; null si el desarrollo aún no tiene */
reglas: ReglasCotizador | null
}
/** Unidad del inventario tal como la devuelve GET /api/unidades */
export interface UnidadDesarrollo {
codigo: string
modelo?: string | null
status?: EstatusUnidad | string | null
/** Alias tolerante: algunas respuestas usan `estatus` en lugar de `status` */
estatus?: EstatusUnidad | string | null
precioSinAcabados?: number | null
precioLista?: number | null
precioConAcabados?: number | null
supConstruccion?: number | null
supExcedente?: number | null
supEstacionamiento?: number | null
fechaEntrega?: string | null
desarrollo?: string | null
etapa?: string | null
/** Nivel/piso de la unidad cuando el inventario lo publica */
nivel?: string | null
extras?: Record<string, string | number | null> | null
}
/** Datos de alta/edición de un desarrollo */
export interface DatosDesarrollo {
clave: string
nombre: string
estado: EstadoDesarrollo
tipo: TipoDesarrollo
activoEnCotizador: boolean
activoEnAlta: boolean
constructora?: string | null
direccion?: string | null
zona?: string | null
descripcion?: string | null
amenidades?: string | null
fechaInicioObra?: string | null
fechaEntregaProgramada?: string | null
fechaEntregaReprogramada?: string | null
prefijoUnidadPdf?: string | null
imagenUrl?: string | null
logoUrl?: string | null
}
export interface DatosModelo {
nombre: string
recamaras?: number | null
banos?: number | null
estacionamientos?: number | null
supConstruccion?: number | null
activo?: boolean
}
/** Una fila de la captura manual de inventario (POST /desarrollos/:id/unidades) */
export interface UnidadManual {
codigo?: string
manzana?: string
lote?: string
nivel?: string
modeloId?: string
precioLista: number
precioAcabados?: number
supConstruccion?: number
supExcedente?: number
supEstacionamiento?: number
}
/** Rango del generador de códigos (POST /desarrollos/:id/unidades) */
export interface GeneradorUnidades {
patron: string
desde: number
hasta: number
modeloId?: string
precioLista: number
precioAcabados?: number
supConstruccion?: number
}
export type BodyCrearUnidades =
| { unidades: UnidadManual[] }
| { generar: GeneradorUnidades }
export interface ResultadoCrearUnidades {
creadas: number
unidades: UnidadDesarrollo[]
}
// ─── Error con el mensaje del servidor ──────────────────────────────
/**
* Error de API que conserva el status HTTP y el motivo enviado por el servidor.
* Los 409 (clave duplicada, modelo o desarrollo con unidades, códigos repetidos,
* etc.) traen el motivo real: se muestra tal cual al usuario.
*/
export class ErrorApi extends Error {
readonly status: number
constructor(mensaje: string, status: number) {
super(mensaje)
this.name = 'ErrorApi'
this.status = status
}
}
async function errorDeRespuesta(resp: Response): Promise<ErrorApi> {
const cuerpo = (await resp.json().catch(() => null)) as {
error?: string
mensaje?: string
motivo?: string
message?: string
} | null
const mensaje =
cuerpo?.error ??
cuerpo?.mensaje ??
cuerpo?.motivo ??
cuerpo?.message ??
`No se pudo completar la operación (HTTP ${resp.status}).`
return new ErrorApi(mensaje, resp.status)
}
function initJson(metodo: string, datos?: unknown): RequestInit {
return {
method: metodo,
headers: { 'Content-Type': 'application/json' },
body: datos === undefined ? undefined : JSON.stringify(datos),
}
}
async function pedir<T>(ruta: string, init: RequestInit = {}): Promise<T> {
const resp = await fetch(`${API_URL}${ruta}`, conAuth(init))
manejar401(resp)
if (!resp.ok) throw await errorDeRespuesta(resp)
return (await resp.json().catch(() => undefined)) as T
}
const rutaDesarrollo = (id: string) => `/desarrollos/${encodeURIComponent(id)}`
// ─── Normalización de la ficha ──────────────────────────────────────
/** El API guarda las reglas envueltas en `{ parametros }`; se acepta ambos formatos */
function normalizarReglas(valor: unknown): ReglasCotizador | null {
if (valor === null || typeof valor !== 'object') return null
const envuelto = valor as { parametros?: unknown }
const parametros = (envuelto.parametros ?? valor) as ReglasCotizador | null
if (parametros === null || typeof parametros !== 'object') return null
return parametros
}
type FichaApi = Omit<DesarrolloFicha, 'reglas' | 'conteosPorEstatus' | 'tieneReglas'> & {
tieneReglas?: boolean
conteosPorEstatus?: Record<string, number>
unidadesPorEstatus?: Record<string, number>
reglas?: unknown
}
function normalizarFicha(api: FichaApi): DesarrolloFicha {
const reglas = normalizarReglas(api.reglas)
return {
...api,
modelos: api.modelos ?? [],
tieneReglas: api.tieneReglas ?? reglas !== null,
conteosPorEstatus: api.conteosPorEstatus ?? api.unidadesPorEstatus ?? {},
reglas,
}
}
/** Estatus de una unidad (el API nuevo usa `status`, el DTO del cotizador también) */
export function estatusDe(unidad: UnidadDesarrollo): string | null {
const valor = unidad.status ?? unidad.estatus
return typeof valor === 'string' && valor !== '' ? valor : null
}
// ─── Desarrollos ────────────────────────────────────────────────────
/** GET /api/desarrollos — listado para las tarjetas */
export async function listarDesarrollos(): Promise<{ desarrollos: DesarrolloResumen[] }> {
const data = await pedir<{ desarrollos?: DesarrolloResumen[] } | DesarrolloResumen[]>('/desarrollos')
if (Array.isArray(data)) return { desarrollos: data }
return {
desarrollos: (data?.desarrollos ?? []).map((d) => ({ ...d, modelos: d.modelos ?? [] })),
}
}
/** GET /api/desarrollos/:id — ficha completa (modelos, inventario y reglas) */
export async function obtenerDesarrollo(id: string): Promise<DesarrolloFicha> {
return normalizarFicha(await pedir<FichaApi>(rutaDesarrollo(id)))
}
/** POST /api/desarrollos — alta */
export async function crearDesarrollo(datos: DatosDesarrollo): Promise<DesarrolloFicha> {
return normalizarFicha(await pedir<FichaApi>('/desarrollos', initJson('POST', datos)))
}
/** PATCH /api/desarrollos/:id — edición parcial */
export async function actualizarDesarrollo(
id: string,
datos: Partial<DatosDesarrollo>,
): Promise<DesarrolloFicha> {
return normalizarFicha(await pedir<FichaApi>(rutaDesarrollo(id), initJson('PATCH', datos)))
}
/**
* DELETE /api/desarrollos/:id — responde 204 si se borró. Con inventario u
* operación responde 409 con el motivo (se propaga tal cual).
*/
export async function eliminarDesarrollo(id: string): Promise<void> {
await pedir<void>(rutaDesarrollo(id), { method: 'DELETE' })
}
// ─── Modelos ────────────────────────────────────────────────────────
/** POST /api/desarrollos/:id/modelos */
export async function crearModelo(
desarrolloId: string,
datos: DatosModelo,
): Promise<ModeloDesarrollo> {
return await pedir<ModeloDesarrollo>(
`${rutaDesarrollo(desarrolloId)}/modelos`,
initJson('POST', datos),
)
}
/** PATCH /api/desarrollos/modelos/:modeloId */
export async function actualizarModelo(
modeloId: string,
datos: Partial<DatosModelo>,
): Promise<ModeloDesarrollo> {
return await pedir<ModeloDesarrollo>(
`/desarrollos/modelos/${encodeURIComponent(modeloId)}`,
initJson('PATCH', datos),
)
}
/** DELETE /api/desarrollos/modelos/:modeloId — 409 si el modelo tiene unidades */
export async function eliminarModelo(modeloId: string): Promise<void> {
await pedir<void>(`/desarrollos/modelos/${encodeURIComponent(modeloId)}`, { method: 'DELETE' })
}
// ─── Reglas del cotizador ───────────────────────────────────────────
/**
* PUT /api/desarrollos/:id/reglas — guarda la parametrización que consume el
* cotizador. Los porcentajes viajan como fracción (0.025 = 2.5%), igual que en
* `apps/cotizador/src/data/desarrollos.ts` y en la validación del API.
*/
export async function guardarReglas(
desarrolloId: string,
reglas: ReglasCotizador,
): Promise<ReglasCotizador | null> {
const data = await pedir<unknown>(`${rutaDesarrollo(desarrolloId)}/reglas`, initJson('PUT', reglas))
return normalizarReglas(data)
}
/**
* GET /api/desarrollos/reglas-cotizador — reglas EFECTIVAS por clave de
* desarrollo (las de la BD y, si no hay, las constantes históricas del
* cotizador). Sirve para precargar el editor de un desarrollo sin reglas.
*/
export async function obtenerReglasEfectivas(): Promise<Record<string, ReglasCotizador>> {
const data = await pedir<{ reglasPorDesarrollo?: Record<string, ReglasCotizador> }>(
'/desarrollos/reglas-cotizador',
)
return data?.reglasPorDesarrollo ?? {}
}
// ─── Inventario ─────────────────────────────────────────────────────
/**
* GET /api/unidades?desarrollo=CLAVE — inventario del desarrollo. El API
* devuelve el arreglo de unidades; el filtrado y la paginación de la tabla se
* hacen en el cliente.
*/
export async function listarUnidades(clave: string): Promise<UnidadDesarrollo[]> {
const data = await pedir<UnidadDesarrollo[] | { unidades?: UnidadDesarrollo[] }>(
`/unidades?desarrollo=${encodeURIComponent(clave.toUpperCase())}`,
)
if (Array.isArray(data)) return data
return data?.unidades ?? []
}
/** POST /api/desarrollos/:id/unidades — alta por generador o captura manual */
export async function crearUnidades(
desarrolloId: string,
body: BodyCrearUnidades,
): Promise<ResultadoCrearUnidades> {
const data = await pedir<{ creadas?: number; unidades?: UnidadDesarrollo[] }>(
`${rutaDesarrollo(desarrolloId)}/unidades`,
initJson('POST', body),
)
const unidades = data?.unidades ?? []
return { creadas: data?.creadas ?? unidades.length, unidades }
}
/** DELETE /api/desarrollos/:id/unidades/:codigo — 409 si la unidad tiene ventas */
export async function eliminarUnidad(desarrolloId: string, codigo: string): Promise<void> {
await pedir<void>(`${rutaDesarrollo(desarrolloId)}/unidades/${encodeURIComponent(codigo)}`, {
method: 'DELETE',
})
}

View File

@@ -0,0 +1,64 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
ErrorDesarrolloNoEncontrado,
ErrorServicioPublico,
listarDesarrollosPublicos,
obtenerDesarrolloPublico,
} from './publico'
type FetchFn = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>
const respuestaOk = (data: unknown) =>
new Response(JSON.stringify(data), { status: 200, headers: { 'Content-Type': 'application/json' } })
let fetchMock: ReturnType<typeof vi.fn<FetchFn>>
beforeEach(() => {
fetchMock = vi.fn<FetchFn>()
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
vi.unstubAllGlobals()
})
describe('servicio publico', () => {
it('pide la ficha sin cabeceras de sesión y normaliza listas faltantes', async () => {
fetchMock.mockResolvedValue(respuestaOk({ clave: 'ATELIER', nombre: 'Atelier' }))
const desarrollo = await obtenerDesarrolloPublico('ATELIER')
expect(String(fetchMock.mock.calls[0][0])).toMatch(/\/publico\/desarrollos\/ATELIER$/)
expect(fetchMock.mock.calls[0][1]).toEqual({ headers: { Accept: 'application/json' } })
expect(desarrollo.estado).toBe('ACTIVO')
expect(desarrollo.tipo).toBe('VERTICAL')
expect(desarrollo.modelos).toEqual([])
expect(desarrollo.unidadesDisponibles).toEqual([])
})
it('traduce el 404 a ErrorDesarrolloNoEncontrado', async () => {
fetchMock.mockResolvedValue(new Response(null, { status: 404 }))
await expect(obtenerDesarrolloPublico('NO-EXISTE')).rejects.toBeInstanceOf(ErrorDesarrolloNoEncontrado)
})
it('distingue el 404 de ruta no publicada (endpoint pendiente) del desarrollo inexistente', async () => {
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ error: 'Ruta no encontrada' }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
}),
)
await expect(obtenerDesarrolloPublico('ATELIER')).rejects.toBeInstanceOf(ErrorServicioPublico)
})
it('lista los desarrollos públicos', async () => {
fetchMock.mockResolvedValue(respuestaOk([{ clave: 'ATELIER', nombre: 'Atelier' }]))
const lista = await listarDesarrollosPublicos()
expect(String(fetchMock.mock.calls[0][0])).toMatch(/\/publico\/desarrollos$/)
expect(lista).toHaveLength(1)
})
})

View File

@@ -0,0 +1,144 @@
import { API_URL } from './auth'
// ─── Tipos (contrato del endpoint público de desarrollos) ───────────
// Estos endpoints NO requieren sesión: se consumen desde el micrositio que ve
// el prospecto en /d/:clave, así que nunca mandan cabeceras de autenticación.
export type EstadoDesarrollo = 'ACTIVO' | 'PREVENTA' | 'CERRADO'
export type TipoDesarrollo = 'VERTICAL' | 'HORIZONTAL'
export interface ModeloPublico {
nombre: string
recamaras?: number | null
banos?: number | null
estacionamientos?: number | null
supConstruccion?: number | null
disponibles: number
precioDesde: number | null
}
export interface UnidadPublica {
codigo: string
modelo: string
precioLista: number
precioAcabados?: number | null
supConstruccion?: number | null
nivel?: string | null
}
export interface DesarrolloPublico {
clave: string
nombre: string
estado: EstadoDesarrollo
tipo: TipoDesarrollo
zona?: string | null
direccion?: string | null
descripcion?: string | null
/** Amenidades como lista separada por comas ("Alberca, Gimnasio, Coworking") */
amenidades?: string | null
imagenUrl?: string | null
logoUrl?: string | null
fechaEntregaProgramada?: string | null
modelos: ModeloPublico[]
unidadesDisponibles: UnidadPublica[]
}
/** Resumen para listados públicos de desarrollos */
export interface ResumenDesarrolloPublico {
clave: string
nombre: string
estado: EstadoDesarrollo
tipo: TipoDesarrollo
zona?: string | null
imagenUrl?: string | null
logoUrl?: string | null
unidadesDisponibles?: number
precioDesde?: number | null
}
// ─── Errores ────────────────────────────────────────────────────────
/** El desarrollo no existe o el enlace ya no es válido (HTTP 404) */
export class ErrorDesarrolloNoEncontrado extends Error {
constructor(mensaje = 'Desarrollo no encontrado') {
super(mensaje)
this.name = 'ErrorDesarrolloNoEncontrado'
}
}
/** El endpoint público respondió con un error distinto a 404 (o no respondió) */
export class ErrorServicioPublico extends Error {
constructor(mensaje: string) {
super(mensaje)
this.name = 'ErrorServicioPublico'
}
}
// ─── Fetch sin sesión ───────────────────────────────────────────────
async function pedirJson<T>(ruta: string): Promise<T> {
let resp: Response
try {
resp = await fetch(`${API_URL}${ruta}`, { headers: { Accept: 'application/json' } })
} catch {
throw new ErrorServicioPublico('No pudimos conectar con el servidor')
}
if (resp.status === 404) {
// El API Gateway responde "Ruta no encontrada" cuando el endpoint todavía no
// está publicado; eso no significa que el desarrollo no exista.
const cuerpo = (await resp.json().catch(() => null)) as { error?: string } | null
if (typeof cuerpo?.error === 'string' && /ruta no encontrada|not found/i.test(cuerpo.error)) {
throw new ErrorServicioPublico('El micrositio público aún no está disponible en este servidor')
}
throw new ErrorDesarrolloNoEncontrado()
}
if (!resp.ok) throw new ErrorServicioPublico(`HTTP ${resp.status}`)
try {
return (await resp.json()) as T
} catch {
throw new ErrorServicioPublico('El servidor devolvió una respuesta inválida')
}
}
const ESTADOS: EstadoDesarrollo[] = ['ACTIVO', 'PREVENTA', 'CERRADO']
function normalizarEstado(valor: unknown): EstadoDesarrollo {
return ESTADOS.includes(valor as EstadoDesarrollo) ? (valor as EstadoDesarrollo) : 'ACTIVO'
}
function normalizarTipo(valor: unknown): TipoDesarrollo {
return valor === 'HORIZONTAL' ? 'HORIZONTAL' : 'VERTICAL'
}
/**
* El backend puede omitir arreglos o mandar nulls; la página nunca recibe
* `undefined` en las listas ni en el estado/tipo.
*/
function normalizarDesarrollo(datos: DesarrolloPublico): DesarrolloPublico {
if (!datos || typeof datos !== 'object' || typeof datos.nombre !== 'string') {
throw new ErrorServicioPublico('El servidor devolvió una respuesta inválida')
}
return {
...datos,
estado: normalizarEstado(datos.estado),
tipo: normalizarTipo(datos.tipo),
modelos: Array.isArray(datos.modelos) ? datos.modelos : [],
unidadesDisponibles: Array.isArray(datos.unidadesDisponibles) ? datos.unidadesDisponibles : [],
}
}
// ─── API pública ────────────────────────────────────────────────────
/** GET /api/publico/desarrollos/:clave — ficha pública de un desarrollo */
export async function obtenerDesarrolloPublico(clave: string): Promise<DesarrolloPublico> {
const datos = await pedirJson<DesarrolloPublico>(
`/publico/desarrollos/${encodeURIComponent(clave.trim())}`,
)
return normalizarDesarrollo(datos)
}
/** GET /api/publico/desarrollos — listado público de desarrollos */
export async function listarDesarrollosPublicos(): Promise<ResumenDesarrolloPublico[]> {
const datos = await pedirJson<ResumenDesarrolloPublico[]>('/publico/desarrollos')
return Array.isArray(datos) ? datos : []
}