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:
@@ -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"
|
||||
|
||||
@@ -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 '';
|
||||
@@ -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?
|
||||
}
|
||||
|
||||
157
api-gateway/scripts/seed-desarrollos.js
Normal file
157
api-gateway/scripts/seed-desarrollos.js
Normal 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())
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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' }])
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
524
api-gateway/src/controllers/desarrollos.controller.test.ts
Normal file
524
api-gateway/src/controllers/desarrollos.controller.test.ts
Normal 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' }))
|
||||
})
|
||||
})
|
||||
141
api-gateway/src/controllers/desarrollos.controller.ts
Normal file
141
api-gateway/src/controllers/desarrollos.controller.ts
Normal 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'])))
|
||||
}
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
14
api-gateway/src/controllers/reglas-cotizador.controller.ts
Normal file
14
api-gateway/src/controllers/reglas-cotizador.controller.ts
Normal 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() })
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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' {
|
||||
|
||||
@@ -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)
|
||||
|
||||
201
api-gateway/src/services/cotizacion-reglas.test.ts
Normal file
201
api-gateway/src/services/cotizacion-reglas.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
496
api-gateway/src/services/desarrollos.service.test.ts
Normal file
496
api-gateway/src/services/desarrollos.service.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
754
api-gateway/src/services/desarrollos.service.ts
Normal file
754
api-gateway/src/services/desarrollos.service.ts
Normal 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)
|
||||
}
|
||||
253
api-gateway/src/services/reglas-desarrollo.service.test.ts
Normal file
253
api-gateway/src/services/reglas-desarrollo.service.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
417
api-gateway/src/services/reglas-desarrollo.service.ts
Normal file
417
api-gateway/src/services/reglas-desarrollo.service.ts
Normal 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]!
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
108
apps/cotizador/src/hooks/useCotizacion.test.tsx
Normal file
108
apps/cotizador/src/hooks/useCotizacion.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
|
||||
164
apps/cotizador/src/lib/reglas.test.ts
Normal file
164
apps/cotizador/src/lib/reglas.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
227
apps/cotizador/src/lib/reglas.ts
Normal file
227
apps/cotizador/src/lib/reglas.ts
Normal 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
|
||||
})
|
||||
}
|
||||
89
apps/cotizador/src/pages/CotizadorPage.reglas.test.tsx
Normal file
89
apps/cotizador/src/pages/CotizadorPage.reglas.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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({})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
@@ -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,6 +156,8 @@ export default function App() {
|
||||
? () => setVista('clientes-maestro')
|
||||
: modulo.id === 'soporte'
|
||||
? () => setVista('soporte')
|
||||
: modulo.id === 'desarrollos'
|
||||
? () => setVista('desarrollos')
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
345
apps/landing/src/components/desarrollos/FichaDesarrollo.tsx
Normal file
345
apps/landing/src/components/desarrollos/FichaDesarrollo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
458
apps/landing/src/components/desarrollos/FormularioDesarrollo.tsx
Normal file
458
apps/landing/src/components/desarrollos/FormularioDesarrollo.tsx
Normal 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 }
|
||||
137
apps/landing/src/components/desarrollos/ListaDesarrollos.tsx
Normal file
137
apps/landing/src/components/desarrollos/ListaDesarrollos.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
64
apps/landing/src/components/desarrollos/ModalDesarrollo.tsx
Normal file
64
apps/landing/src/components/desarrollos/ModalDesarrollo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
761
apps/landing/src/components/desarrollos/TabInventario.tsx
Normal file
761
apps/landing/src/components/desarrollos/TabInventario.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
360
apps/landing/src/components/desarrollos/TabModelos.tsx
Normal file
360
apps/landing/src/components/desarrollos/TabModelos.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
558
apps/landing/src/components/desarrollos/TabReglas.tsx
Normal file
558
apps/landing/src/components/desarrollos/TabReglas.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
189
apps/landing/src/components/desarrollos/comun.tsx
Normal file
189
apps/landing/src/components/desarrollos/comun.tsx
Normal 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)
|
||||
}
|
||||
46
apps/landing/src/components/desarrollos/generador.ts
Normal file
46
apps/landing/src/components/desarrollos/generador.ts
Normal 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
|
||||
}
|
||||
164
apps/landing/src/components/desarrollos/reglas.ts
Normal file
164
apps/landing/src/components/desarrollos/reglas.ts
Normal 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
|
||||
}
|
||||
140
apps/landing/src/components/publico/EncabezadoDesarrollo.tsx
Normal file
140
apps/landing/src/components/publico/EncabezadoDesarrollo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
45
apps/landing/src/components/publico/EsqueletoDesarrollo.tsx
Normal file
45
apps/landing/src/components/publico/EsqueletoDesarrollo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
70
apps/landing/src/components/publico/EstadosDesarrollo.tsx
Normal file
70
apps/landing/src/components/publico/EstadosDesarrollo.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
36
apps/landing/src/components/publico/FranjaDatos.tsx
Normal file
36
apps/landing/src/components/publico/FranjaDatos.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
81
apps/landing/src/components/publico/PieContacto.tsx
Normal file
81
apps/landing/src/components/publico/PieContacto.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
69
apps/landing/src/components/publico/SeccionDescripcion.tsx
Normal file
69
apps/landing/src/components/publico/SeccionDescripcion.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
apps/landing/src/components/publico/SeccionModelos.tsx
Normal file
39
apps/landing/src/components/publico/SeccionModelos.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
318
apps/landing/src/components/publico/TablaDisponibilidad.tsx
Normal file
318
apps/landing/src/components/publico/TablaDisponibilidad.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
83
apps/landing/src/components/publico/TarjetaModelo.tsx
Normal file
83
apps/landing/src/components/publico/TarjetaModelo.tsx
Normal 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">→</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
75
apps/landing/src/components/publico/contacto.ts
Normal file
75
apps/landing/src/components/publico/contacto.ts
Normal 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
|
||||
}
|
||||
91
apps/landing/src/components/publico/formato.ts
Normal file
91
apps/landing/src/components/publico/formato.ts
Normal 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()}…`
|
||||
}
|
||||
@@ -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',
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
]
|
||||
|
||||
/**
|
||||
|
||||
225
apps/landing/src/pages/DesarrolloPublicoPage.test.tsx
Normal file
225
apps/landing/src/pages/DesarrolloPublicoPage.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
166
apps/landing/src/pages/DesarrolloPublicoPage.tsx
Normal file
166
apps/landing/src/pages/DesarrolloPublicoPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
357
apps/landing/src/pages/DesarrollosPage.test.tsx
Normal file
357
apps/landing/src/pages/DesarrollosPage.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
135
apps/landing/src/pages/DesarrollosPage.tsx
Normal file
135
apps/landing/src/pages/DesarrollosPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
384
apps/landing/src/services/desarrollos.ts
Normal file
384
apps/landing/src/services/desarrollos.ts
Normal 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',
|
||||
})
|
||||
}
|
||||
64
apps/landing/src/services/publico.test.ts
Normal file
64
apps/landing/src/services/publico.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
144
apps/landing/src/services/publico.ts
Normal file
144
apps/landing/src/services/publico.ts
Normal 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 : []
|
||||
}
|
||||
Reference in New Issue
Block a user