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

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

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

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

View File

@@ -2,7 +2,7 @@ import { useMemo, useState } from 'react'
import type { Unidad, TipoPropuesta } from '../types'
import { compararUnidades, type ColumnaComparador } from '../lib/comparadorUnidades'
import { formatoMoneda } from '../lib/formatters'
import { LISTA_DESARROLLOS, obtenerDesarrollo } from '../data/desarrollos'
import { SIN_REGLAS, desarrolloEfectivo, idsCotizables, type ReglasDesarrollo } from '../lib/reglas'
interface Props {
unidades: Unidad[]
@@ -10,6 +10,8 @@ interface Props {
tipo: TipoPropuesta
apartado: number
acabados: boolean
/** Reglas comerciales por desarrollo (vacío => constantes) */
reglas?: ReglasDesarrollo
}
const formatoM2 = (v: number | null): string => (v === null ? '—' : `${v.toLocaleString('es-MX')} m²`)
@@ -23,17 +25,25 @@ interface Fila {
/**
* Comparador de 2 unidades (del mismo o distinto desarrollo), lado a lado,
* con la propuesta activa calculada por el motor con los parámetros de cada
* desarrollo. Solo ofrece unidades DISPONIBLES: no se compara (ni se cotiza)
* inventario apartado, vendido o bloqueado.
* desarrollo (las reglas del admin con respaldo en las constantes). Solo ofrece
* unidades DISPONIBLES de desarrollos activos en el cotizador: no se compara
* (ni se cotiza) inventario apartado, vendido, bloqueado o desactivado.
*/
export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props) {
export function ComparadorUnidades({ unidades, tipo, apartado, acabados, reglas = SIN_REGLAS }: Props) {
const [abierto, setAbierto] = useState(false)
const [codigoA, setCodigoA] = useState('')
const [codigoB, setCodigoB] = useState('')
const desarrollos = useMemo(() => idsCotizables(reglas), [reglas])
const disponibles = useMemo(
() => unidades.filter((u) => (u.status ?? '').trim().toUpperCase() === 'DISPONIBLE'),
[unidades],
() =>
unidades.filter(
(u) =>
(u.status ?? '').trim().toUpperCase() === 'DISPONIBLE' &&
desarrollos.includes(u.desarrollo ?? 'DIAGONAL'),
),
[unidades, desarrollos],
)
const porCodigo = useMemo(() => {
@@ -46,8 +56,11 @@ export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props
const unidadB = porCodigo.get(codigoB) ?? null
const comparacion = useMemo(
() => (unidadA && unidadB ? compararUnidades(unidadA, unidadB, tipo, apartado, acabados) : null),
[unidadA, unidadB, tipo, apartado, acabados],
() =>
unidadA && unidadB
? compararUnidades(unidadA, unidadB, tipo, apartado, acabados, reglas)
: null,
[unidadA, unidadB, tipo, apartado, acabados, reglas],
)
const filas: Fila[] = [
@@ -87,11 +100,11 @@ export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props
className="w-full rounded-lg border border-[#D1D5DB] bg-white px-3 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none"
>
<option value="">Selecciona una unidad…</option>
{LISTA_DESARROLLOS.map((dev) => {
{desarrollos.map((dev) => {
const delDesarrollo = disponibles.filter((u) => (u.desarrollo ?? 'DIAGONAL') === dev)
if (delDesarrollo.length === 0) return null
return (
<optgroup key={dev} label={obtenerDesarrollo(dev).nombre}>
<optgroup key={dev} label={desarrolloEfectivo(reglas, dev).nombre}>
{delDesarrollo.map((u) => (
<option key={`${dev}|${u.codigo}`} value={`${dev}|${u.codigo}`}>
{u.codigo} — {u.modelo}

View File

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

View File

@@ -0,0 +1,108 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from 'vitest'
import { act, cleanup, renderHook } from '@testing-library/react'
import { useCotizacion } from './useCotizacion'
import { reglasDesdeApi, SIN_REGLAS, type ReglasDesarrollo } from '../lib/reglas'
import { DESARROLLOS } from '../data/desarrollos'
import { INVENTARIO_SEED } from '../data/unidades'
/**
* Reglas comerciales configurables en el cotizador: las propuestas desactivadas
* por el admin dejan de ofrecerse y, si la elegida se desactiva, se avisa y se
* cae a la primera vigente.
*/
const unidadDiagonal = INVENTARIO_SEED.find((u) => u.desarrollo === 'DIAGONAL')!
function reglasDiagonales(activa: (tipo: string) => boolean): ReglasDesarrollo {
return reglasDesdeApi({
DIAGONAL: {
propuestas: DESARROLLOS['DIAGONAL']!.propuestas.map((p) => ({ ...p, activa: activa(p.tipo) })),
},
})
}
function montar(reglas: ReglasDesarrollo = SIN_REGLAS) {
const hook = renderHook(({ reglas }) => useCotizacion(reglas), {
initialProps: { reglas: reglas as ReglasDesarrollo },
})
act(() => {
hook.result.current.setUnidad(unidadDiagonal)
})
return hook
}
afterEach(cleanup)
describe('useCotizacion y las reglas del admin', () => {
it('sin reglas ofrece las 4 propuestas de siempre', () => {
const { result } = montar()
expect(result.current.propuestas.map((p) => p.tipo)).toEqual([
'CREDITO_BANCARIO',
'CREDITO_INFONAVIT_COFINAVIT',
'CONTADO',
'SEMI_CONTADO',
])
expect(result.current.avisoPropuestaDesactivada).toBeNull()
})
it('una propuesta desactivada no se ofrece ni se puede elegir', () => {
const { result } = montar(reglasDiagonales((tipo) => tipo !== 'CONTADO'))
expect(result.current.propuestas.map((p) => p.tipo)).toEqual([
'CREDITO_BANCARIO',
'CREDITO_INFONAVIT_COFINAVIT',
'SEMI_CONTADO',
])
})
it('si el admin desactiva la propuesta elegida, avisa y cae a la primera vigente', () => {
const { result, rerender } = montar()
// El vendedor elige Contado (índice 2)
act(() => {
result.current.setPropuestaIndex(2)
})
expect(result.current.propuestas[2]?.tipo).toBe('CONTADO')
// El admin la desactiva (llegan reglas nuevas)
rerender({ reglas: reglasDiagonales((tipo) => tipo !== 'CONTADO') })
expect(result.current.propuestas.map((p) => p.tipo)).not.toContain('CONTADO')
expect(result.current.propuestaIndex).toBe(0)
expect(result.current.avisoPropuestaDesactivada).toContain('Contado')
expect(result.current.avisoPropuestaDesactivada).toContain('Crédito Bancario')
})
it('los descuentos por defecto salen de las reglas configuradas', () => {
const { result } = montar(
reglasDesdeApi({
DIAGONAL: {
propuestas: DESARROLLOS['DIAGONAL']!.propuestas.map((p) =>
p.tipo === 'CONTADO' ? { ...p, descuentoDefault: 0.06 } : p,
),
},
}),
)
expect(result.current.descuentos.contado).toBe(0.06)
expect(result.current.descuentos.semiContado).toBe(0.01)
})
it('las reglas cambian los números de las propuestas', () => {
const { result } = montar(
reglasDesdeApi({
DIAGONAL: {
propuestas: DESARROLLOS['DIAGONAL']!.propuestas.map((p) =>
p.tipo === 'CREDITO_BANCARIO' ? { ...p, pctEnganche: 0.05 } : p,
),
},
}),
)
const bancario = result.current.propuestas.find((p) => p.tipo === 'CREDITO_BANCARIO')
const precioLista = result.current.precioLista
expect(bancario?.enganche).toBeCloseTo(0.05 * precioLista - result.current.apartadoEfectivo, 2)
})
})

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import type { Unidad, DescuentosPropuestas, ParametrosDesarrollo } from '../types'
import {
APARTADO_DEFAULT,
@@ -8,7 +8,14 @@ import {
validarEnganchePct,
type PropuestaResultado,
} from '../lib/calculations'
import { DESARROLLO_DEFAULT, descuentosDefault, obtenerDesarrollo } from '../data/desarrollos'
import { DESARROLLO_DEFAULT } from '../data/desarrollos'
import {
SIN_REGLAS,
descuentosDefaultEfectivo,
desarrolloEfectivo,
propuestasVigentes,
type ReglasDesarrollo,
} from '../lib/reglas'
interface UseCotizacionResult {
desarrollo: string
@@ -40,6 +47,8 @@ interface UseCotizacionResult {
/** Número efectivo de mensualidades (la elegida o la calculada) */
numMeses: number | null
propuestas: PropuestaResultado[]
/** Aviso cuando el admin desactivó la propuesta que estaba elegida; null si no aplica */
avisoPropuestaDesactivada: string | null
/** Restaura el estado completo desde una cotización guardada (historial) */
restaurar: (estado: EstadoRestaurar) => void
/** Reinicia la cotización (botón "Nueva") */
@@ -59,24 +68,47 @@ export interface EstadoRestaurar {
numMensualidades?: number | null
}
/** Estado central de la cotización: desarrollo, unidad, acabados, apartado y descuentos */
export function useCotizacion(): UseCotizacionResult {
/**
* Estado central de la cotización: desarrollo, unidad, acabados, apartado y
* descuentos. Las reglas comerciales llegan del API (con respaldo en las
* constantes): si el admin desactiva una propuesta, deja de ofrecerse y, si era
* la elegida, se avisa y se cae a la primera vigente.
*/
export function useCotizacion(reglas: ReglasDesarrollo = SIN_REGLAS): UseCotizacionResult {
const [desarrollo, setDesarrolloState] = useState<string>(DESARROLLO_DEFAULT)
const [unidad, setUnidad] = useState<Unidad | null>(null)
const [acabados, setAcabados] = useState(false)
const [apartado, setApartado] = useState<number>(APARTADO_DEFAULT)
const [enganchePct, setEnganchePct] = useState<number | null>(null)
const [descuentos, setDescuentos] = useState<DescuentosPropuestas>(descuentosDefault(DESARROLLO_DEFAULT))
const [descuentos, setDescuentos] = useState<DescuentosPropuestas>(
descuentosDefaultEfectivo(reglas, DESARROLLO_DEFAULT),
)
const [propuestaIndex, setPropuestaIndex] = useState(0)
const [numMensualidades, setNumMensualidades] = useState<number | null>(null)
const [avisoPropuestaDesactivada, setAvisoPropuestaDesactivada] = useState<string | null>(null)
const params = obtenerDesarrollo(desarrollo)
const params = desarrolloEfectivo(reglas, desarrollo)
// Los descuentos por defecto se re-aplican cuando cambian las reglas del
// desarrollo (p. ej. al llegar las del API), sin pisar lo que capturó el
// vendedor mientras las reglas no cambian.
const descuentosAplicados = useRef<string>('')
const defaults = useMemo(
() => descuentosDefaultEfectivo(reglas, desarrollo),
[reglas, desarrollo],
)
useEffect(() => {
const clave = JSON.stringify(defaults)
if (descuentosAplicados.current === clave) return
descuentosAplicados.current = clave
setDescuentos(defaults)
}, [defaults])
const setDesarrollo = (id: string) => {
setDesarrolloState(id)
setUnidad(null)
setAcabados(false)
setDescuentos(descuentosDefault(id))
setDescuentos(descuentosDefaultEfectivo(reglas, id))
setPropuestaIndex(0)
setNumMensualidades(null)
}
@@ -90,10 +122,12 @@ export function useCotizacion(): UseCotizacionResult {
setUnidad(estado.unidad)
setAcabados(estado.acabados)
setApartado(estado.apartado)
const base = descuentosDefault(estado.desarrollo)
const base = descuentosDefaultEfectivo(reglas, estado.desarrollo)
const paramsRestaurados = desarrolloEfectivo(reglas, estado.desarrollo)
const vigentes = propuestasVigentes(paramsRestaurados)
if (estado.descuento !== undefined) {
const idx = estado.propuestaIndex ?? 0
const clave = obtenerDesarrollo(estado.desarrollo).propuestas[idx]?.claveDescuento
const clave = vigentes[idx]?.claveDescuento
if (clave) base[clave] = estado.descuento
}
setDescuentos(base)
@@ -106,9 +140,10 @@ export function useCotizacion(): UseCotizacionResult {
setAcabados(false)
setApartado(APARTADO_DEFAULT)
setEnganchePct(null)
setDescuentos(descuentosDefault(desarrollo))
setDescuentos(descuentosDefaultEfectivo(reglas, desarrollo))
setPropuestaIndex(0)
setNumMensualidades(null)
setAvisoPropuestaDesactivada(null)
}
const numMesesCalculado = useMemo(
@@ -134,8 +169,40 @@ export function useCotizacion(): UseCotizacionResult {
const propuestas = useMemo(() => {
if (!unidad) return []
return calcularPropuestas({ unidad, acabados, apartado: apartadoEfectivo, descuentos, desarrollo, enganchePct: engancheEfectivo, numMeses: numMeses ?? undefined })
}, [unidad, acabados, apartadoEfectivo, descuentos, desarrollo, engancheEfectivo, numMeses])
return calcularPropuestas({
unidad,
acabados,
apartado: apartadoEfectivo,
descuentos,
desarrollo,
reglas: params,
enganchePct: engancheEfectivo,
numMeses: numMeses ?? undefined,
})
}, [unidad, acabados, apartadoEfectivo, descuentos, desarrollo, params, engancheEfectivo, numMeses])
// Propuesta elegida: si el índice se sale de la lista vigente (p. ej. el admin
// desactivó una propuesta) se cae a la primera y, si la que estaba elegida ya
// no existe, se avisa con claridad.
const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))]
const tipoPrevio = useRef<{ tipo: string; titulo: string } | null>(null)
useEffect(() => {
const previo = tipoPrevio.current
const vigentes = propuestasVigentes(params)
if (previo !== null && !vigentes.some((p) => p.tipo === previo.tipo)) {
const sustituto = propuestas[0]?.titulo ?? 'otra propuesta'
setAvisoPropuestaDesactivada(
`El administrador desactivó la propuesta ${previo.titulo} para ${params.nombre}. Se seleccionó ${sustituto}; elige otra si lo prefieres.`,
)
setPropuestaIndex(0)
} else if (propuestaIndex >= propuestas.length) {
setPropuestaIndex(0)
}
tipoPrevio.current = propuestaActiva
? { tipo: propuestaActiva.tipo, titulo: propuestaActiva.titulo }
: null
}, [params, propuestas, propuestaActiva, propuestaIndex])
return {
desarrollo,
@@ -160,6 +227,7 @@ export function useCotizacion(): UseCotizacionResult {
setNumMensualidades,
numMeses,
propuestas,
avisoPropuestaDesactivada,
restaurar,
nueva,
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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