feat(pendientes): cotizador (mensualidades, bloqueo de unidad, totales, numMensualidades), cobranza (recibos, atrasos, mora configurable, recordatorios), soporte (adjuntos, filtros, estados), clientes maestro (catalogos reales), contactos (ficha y duplicados) y permisos de captura por rol

This commit is contained in:
2026-09-21 08:34:53 +00:00
parent b5a20aed4e
commit 2fc80e7f9e
66 changed files with 3792 additions and 436 deletions

View File

@@ -23,18 +23,24 @@ 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.
* desarrollo. Solo ofrece unidades DISPONIBLES: no se compara (ni se cotiza)
* inventario apartado, vendido o bloqueado.
*/
export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props) {
const [abierto, setAbierto] = useState(false)
const [codigoA, setCodigoA] = useState('')
const [codigoB, setCodigoB] = useState('')
const disponibles = useMemo(
() => unidades.filter((u) => (u.status ?? '').trim().toUpperCase() === 'DISPONIBLE'),
[unidades],
)
const porCodigo = useMemo(() => {
const m = new Map<string, Unidad>()
for (const u of unidades) m.set(`${u.desarrollo ?? 'DIAGONAL'}|${u.codigo}`, u)
for (const u of disponibles) m.set(`${u.desarrollo ?? 'DIAGONAL'}|${u.codigo}`, u)
return m
}, [unidades])
}, [disponibles])
const unidadA = porCodigo.get(codigoA) ?? null
const unidadB = porCodigo.get(codigoB) ?? null
@@ -82,7 +88,7 @@ export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props
>
<option value="">Selecciona una unidad…</option>
{LISTA_DESARROLLOS.map((dev) => {
const delDesarrollo = unidades.filter((u) => (u.desarrollo ?? 'DIAGONAL') === dev)
const delDesarrollo = disponibles.filter((u) => (u.desarrollo ?? 'DIAGONAL') === dev)
if (delDesarrollo.length === 0) return null
return (
<optgroup key={dev} label={obtenerDesarrollo(dev).nombre}>
@@ -172,8 +178,8 @@ export function ComparadorUnidades({ unidades, tipo, apartado, acabados }: Props
</tbody>
</table>
<p className="mt-6 text-xs text-[#64748B] dark:text-[#94A3B8]">
Cada unidad se calcula con los parámetros de su desarrollo y el mismo apartado.
El enganche ya incluye la deducción del apartado.
Solo se ofrecen unidades DISPONIBLES. Cada unidad se calcula con los parámetros de
su desarrollo y el mismo apartado. El enganche ya incluye la deducción del apartado.
</p>
</div>
)}

View File

@@ -27,6 +27,15 @@ const formatoFechaHora = (iso: string): string =>
minute: '2-digit',
})
/**
* Mensualidades guardadas: "N × $m". Las cotizaciones viejas (plazo 0/null, sin
* mensualidad) muestran "—".
*/
const formatoMensualidades = (c: CotizacionGuardada): string => {
if (c.mensualidad === null || c.numMensualidades === 0) return '—'
return `${c.numMensualidades} × ${formatoMoneda(c.mensualidad)}`
}
/** Sección colapsable "Mis cotizaciones": historial del vendedor logueado */
export function MisCotizaciones({ cotizaciones, cargando, error, onRetomar }: Props) {
const [abierto, setAbierto] = useState(false)
@@ -65,7 +74,7 @@ export function MisCotizaciones({ cotizaciones, cargando, error, onRetomar }: Pr
<table className="w-full min-w-[640px] border-collapse text-sm">
<thead>
<tr className="bg-[#F8FAFC] dark:bg-[#0F172A]">
{['Fecha', 'Folio', 'Cliente', 'Unidad', 'Propuesta', 'Precio final', ''].map(
{['Fecha', 'Folio', 'Cliente', 'Unidad', 'Propuesta', 'Mensualidades', 'Precio final', ''].map(
(h) => (
<th
key={h}
@@ -95,6 +104,9 @@ export function MisCotizaciones({ cotizaciones, cargando, error, onRetomar }: Pr
<td className="border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 whitespace-nowrap">
{TITULO_PROPUESTA[c.tipoPropuesta] ?? c.tipoPropuesta}
</td>
<td className="border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 whitespace-nowrap">
{formatoMensualidades(c)}
</td>
<td className="border-t border-[#E5E7EB] dark:border-[#1E293B] p-3 text-right whitespace-nowrap">
{formatoMoneda(c.precioFinal)}
</td>

View File

@@ -12,6 +12,9 @@ const DIAGONAL: ParametrosDesarrollo = {
nombre: 'Diagonal',
precioUnico: false,
fuenteDeN: 'fechaEntrega',
// Último recurso si la unidad no trae fecha de entrega: mediana de la
// terminación de obra del inventario disponible (2026-09) = 13 mensualidades
nDefault: 13,
cuotaMantenimiento: 1500,
prefijoUnidadPdf: 'CASA',
propuestas: [
@@ -180,7 +183,10 @@ const SENDA: ParametrosDesarrollo = {
id: 'SENDA',
nombre: 'Senda',
precioUnico: true,
fuenteDeN: 'fechaEntrega', // unidades sin fecha en TERM => N null (mensualidades "—")
fuenteDeN: 'fechaEntrega', // N = meses hasta TERM. OBRA (captura ESCRITURACION)
// Último recurso si la unidad no trae fecha: mediana del inventario
// disponible (2026-09) = 6 mensualidades
nDefault: 6,
cuotaMantenimiento: null,
prefijoUnidadPdf: 'CASA',
propuestas: [
@@ -236,6 +242,9 @@ const SOLANA: ParametrosDesarrollo = {
nombre: 'Solana',
precioUnico: true,
fuenteDeN: 'porUnidad', // MESES PARA FIRMA APROX del inventario (1–4)
// Último recurso si la unidad no trae meses para firma: las casas vendibles
// del inventario Excel están en 1 mes
nDefault: 1,
cuotaMantenimiento: null,
prefijoUnidadPdf: 'CASA',
propuestas: [

View File

@@ -55,6 +55,8 @@ export interface EstadoRestaurar {
/** Descuento (fracción) de la propuesta elegida; se aplica sobre su clave */
descuento?: number
propuestaIndex?: number
/** Mensualidades elegidas al guardar la cotización (null = usar el plazo calculado) */
numMensualidades?: number | null
}
/** Estado central de la cotización: desarrollo, unidad, acabados, apartado y descuentos */
@@ -96,6 +98,7 @@ export function useCotizacion(): UseCotizacionResult {
}
setDescuentos(base)
setPropuestaIndex(estado.propuestaIndex ?? 0)
setNumMensualidades(estado.numMensualidades ?? null)
}
const nueva = () => {

View File

@@ -169,7 +169,7 @@ describe('SENDA — 411-1-7 (L = 2,451,750, apartado 0 en el Excel, N = 4)', ()
describe('SENDA — unidad sin fecha de entrega (411-25-A101)', () => {
const unidad = porCodigo(UNIDADES_SENDA, 'SENDA-411-25-A101')
it('N = null y mensualidades "—" con nota; enganche y pago final sí se calculan', () => {
it('usa el plazo por defecto del desarrollo: mensualidad y total SÍ se calculan', () => {
expect(unidad.fechaEntrega).toBeNull()
const [bancario] = calcularPropuestas({
unidad,
@@ -177,13 +177,13 @@ describe('SENDA — unidad sin fecha de entrega (411-25-A101)', () => {
apartado: APARTADO_DEFAULT,
descuentos: descuentosDefault('SENDA'),
})
expect(bancario.numMensualidades).toBeNull()
expect(bancario.mensualidad).toBeNull()
expect(bancario.mensualidadesTotal).toBeNull()
expect(bancario.notaMensualidades).toBe('sin fecha de entrega')
// Antes: numMensualidades/mensualidad null y "— (sin fecha de entrega)"
expect(bancario.numMensualidades).toBe(DESARROLLOS['SENDA'].nDefault)
expect(bancario.notaMensualidades).toBeUndefined()
expect(bancario.mensualidad).not.toBeNull()
expect(bancario.enganche).toBeCloseTo(0.05 * 1948000 - APARTADO_DEFAULT, 2)
expect(bancario.pagoFinal).toBeCloseTo(0.9 * 1948000, 2)
expect(totalPagado(bancario, APARTADO_DEFAULT)).toBeNull()
expect(totalPagado(bancario, APARTADO_DEFAULT)).toBeCloseTo(1948000, 2)
})
})
@@ -209,11 +209,12 @@ describe('SOLANA — casa 6 (L = 2,560,000, apartado 20,000, mesesParaFirma = 1)
expect(p.pagoFinal).toBeCloseTo(2304000, 2)
})
it('INFONAVIT: enganche 0% con guarda (no resta el apartado)', () => {
it('INFONAVIT: enganche 0% y el apartado se descuenta de las mensualidades', () => {
const [, p] = calcular()
expect(p.enganche).toBe(0)
expect(p.mensualidadesTotal).toBeCloseTo(128000, 2)
expect(p.mensualidadesTotal).toBeCloseTo(108000, 2)
expect(p.pagoFinal).toBeCloseTo(2432000, 2)
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(2560000, 2)
})
it('CONTADO', () => {
@@ -240,19 +241,115 @@ describe('SOLANA — casa 9 (L = 2,605,000, mesesParaFirma = 1)', () => {
descuentos: descuentosDefault('SOLANA'),
})
it('las 4 propuestas al centavo', () => {
it('las 4 propuestas al centavo (Infonavit descuenta el apartado)', () => {
const [bancario, infonavit, contado, semi] = calcular()
expect(bancario.enganche).toBeCloseTo(110250, 2)
expect(bancario.mensualidadesTotal).toBeCloseTo(130250, 2)
expect(bancario.pagoFinal).toBeCloseTo(2344500, 2)
expect(infonavit.enganche).toBe(0)
expect(infonavit.mensualidadesTotal).toBeCloseTo(130250, 2)
expect(infonavit.mensualidadesTotal).toBeCloseTo(110250, 2)
expect(infonavit.pagoFinal).toBeCloseTo(2474750, 2)
expect(contado.enganche).toBeCloseTo(2324500, 2)
expect(contado.pagoFinal).toBeCloseTo(260500, 2)
expect(semi.enganche).toBeCloseTo(1282500, 2)
expect(semi.mensualidadesTotal).toBeCloseTo(1042000, 2)
expect(semi.pagoFinal).toBeCloseTo(260500, 2)
for (const p of [bancario, infonavit, contado, semi]) {
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(2605000, 2)
}
})
})
describe('cuadre del total: apartado y enganche personalizado', () => {
const TIPOS = [
'CREDITO_BANCARIO',
'CREDITO_INFONAVIT_COFINAVIT',
'CONTADO',
'SEMI_CONTADO',
] as const
const CASOS = [
{ desarrollo: 'DIAGONAL', precio: 4_415_000 },
{ desarrollo: 'ATELIER', precio: 3_997_500 },
{ desarrollo: 'ESTANZA', precio: 2_437_250 },
{ desarrollo: 'SENDA', precio: 2_451_750 },
{ desarrollo: 'SOLANA', precio: 2_560_000 },
]
const unidadDe = (desarrollo: string, precio: number): Unidad => ({
codigo: `${desarrollo}-TEST`,
modelo: 'X',
desarrollo,
fechaEntrega: null,
mesesParaFirma: null,
supEstacionamiento: 0,
supConstruccion: 0,
supExcedente: 0,
precioSinAcabados: precio,
precioConAcabados: precio,
status: 'DISPONIBLE',
})
const calcular = (caso: (typeof CASOS)[number], tipo: (typeof TIPOS)[number], apartado: number, enganchePct?: number) =>
calcularPropuestas({
unidad: unidadDe(caso.desarrollo, caso.precio),
acabados: false,
apartado,
descuentos: descuentosDefault(caso.desarrollo),
enganchePct: enganchePct ?? null,
}).find((p) => p.tipo === tipo)!
it('las 4 propuestas de cada desarrollo cuestan lo que el precio final: ni más ni menos', () => {
for (const caso of CASOS) {
for (const tipo of TIPOS) {
const p = calcular(caso, tipo, APARTADO_DEFAULT)
expect(totalPagado(p, APARTADO_DEFAULT)).toBeCloseTo(p.precioFinal, 2)
}
}
})
it('nunca hay bloques negativos y el total es el precio (apartados y enganches extremos)', () => {
for (const caso of CASOS) {
for (const tipo of TIPOS) {
for (const apartado of [0, APARTADO_DEFAULT, 200_000]) {
for (const enganchePct of [undefined, 0.05, 0.2, 0.5, 1]) {
const p = calcular(caso, tipo, apartado, enganchePct)
expect(p.enganche).toBeGreaterThanOrEqual(0)
expect(p.mensualidadesTotal ?? 0).toBeGreaterThanOrEqual(0)
expect(p.pagoFinal).toBeGreaterThanOrEqual(0)
expect(totalPagado(p, apartado)).toBeCloseTo(p.precioFinal, 2)
}
}
}
}
})
it('enganche personalizado alto (20%) no infla el total: sigue siendo el precio', () => {
const u101 = unidadDe('DIAGONAL', 4_415_000)
const [bancario] = calcularPropuestas({
unidad: u101,
acabados: false,
apartado: APARTADO_DEFAULT,
descuentos: descuentosDefault('DIAGONAL'),
enganchePct: 0.2,
})
// Antes: 20% de enganche + 90% de pago final daban $4,856,500 sobre $4,415,000
expect(totalPagado(bancario, APARTADO_DEFAULT)).toBeCloseTo(4_415_000, 2)
expect(bancario.mensualidadesTotal).toBe(0)
expect(bancario.enganche).toBeCloseTo(0.2 * 4_415_000 - APARTADO_DEFAULT, 2)
})
it('apartado mayor al enganche: el enganche queda en 0 y el total cuadra', () => {
const u101 = unidadDe('DIAGONAL', 4_415_000)
const [bancario] = calcularPropuestas({
unidad: u101,
acabados: false,
// 2.5% de 4,415,000 = 110,375 → con apartado 200,000 el neto sería −89,625
apartado: 200_000,
descuentos: descuentosDefault('DIAGONAL'),
})
expect(bancario.enganche).toBe(0)
expect(bancario.mensualidadesTotal).toBeCloseTo(4_415_000 - 200_000 - bancario.pagoFinal, 2)
expect(totalPagado(bancario, 200_000)).toBeCloseTo(4_415_000, 2)
})
})

View File

@@ -261,11 +261,16 @@ describe('enganche personalizado (mínimo 2.5% del valor de la propiedad)', () =
})
describe('validarEnganchePct', () => {
it('null es válido (sin override); mínimo 2.5%; máximo 100%', () => {
it('null es válido (sin override); rango válido del Excel: 2.5% a 5%', () => {
expect(validarEnganchePct(null)).toBeNull()
expect(validarEnganchePct(2.5)).toBeNull()
expect(validarEnganchePct(50)).toBeNull()
expect(validarEnganchePct(5)).toBeNull()
expect(validarEnganchePct(2.4)).toBe('El enganche mínimo es 2.5% del valor de la propiedad')
expect(validarEnganchePct(101)).toBe('El enganche no puede ser mayor al 100%')
expect(validarEnganchePct(5.5)).toBe(
'El enganche máximo es 5% del valor de la propiedad (rango válido: 2.5% a 5%)',
)
expect(validarEnganchePct(20)).toBe(
'El enganche máximo es 5% del valor de la propiedad (rango válido: 2.5% a 5%)',
)
})
})

View File

@@ -23,16 +23,22 @@ export const APARTADO_DEFAULT = 20000
/** Enganche mínimo seleccionable: 2.5% del valor de la propiedad (fracción) */
export const ENGANCHE_MINIMO_PCT = 0.025
/** Enganche máximo que permite el Excel: 5% (lista de validación 0%, 2.5%, 5%) */
export const ENGANCHE_MAXIMO_PCT = 5
/**
* Mensaje de error si el enganche capturado (en PORCENTAJE, 2.5 = 2.5%) es
* inválido; null si es válido. null de entrada = sin override (default Excel).
* El rango válido es el del Excel: 2.5% a 5%.
*/
export function validarEnganchePct(pct: number | null): string | null {
if (pct === null) return null
if (!Number.isFinite(pct) || pct < 2.5) {
return 'El enganche mínimo es 2.5% del valor de la propiedad'
}
if (pct > 100) return 'El enganche no puede ser mayor al 100%'
if (pct > ENGANCHE_MAXIMO_PCT) {
return 'El enganche máximo es 5% del valor de la propiedad (rango válido: 2.5% a 5%)'
}
return null
}
@@ -100,6 +106,10 @@ export function calcularNumMensualidades(fechaEntrega: string, hoy: Date = new D
* - fijo: nFijo (entrega inmediata)
* - porUnidad: mesesParaFirma del inventario (null si falta)
* - fechaEntrega: meses desde hoy (null si la unidad no tiene fecha)
*
* Cuando el inventario no trae el dato se usa el plazo por defecto del
* desarrollo (`nDefault`), para que la mensualidad y el total SIEMPRE se
* calculen (antes salían "— (sin fecha de entrega)" y sin total).
*/
export function resolverNumMensualidades(
unidad: Unidad,
@@ -109,9 +119,11 @@ export function resolverNumMensualidades(
case 'fijo':
return params.nFijo ?? 1
case 'porUnidad':
return unidad.mesesParaFirma ?? null
return unidad.mesesParaFirma ?? params.nDefault ?? null
case 'fechaEntrega':
return unidad.fechaEntrega ? calcularNumMensualidades(unidad.fechaEntrega) : null
return unidad.fechaEntrega
? calcularNumMensualidades(unidad.fechaEntrega)
: (params.nDefault ?? null)
}
}
@@ -122,6 +134,42 @@ function baseComun(precioLista: number, descuento: number) {
return { montoDescuento, precioFinal }
}
/** Bloques de pago de una propuesta: enganche NETO del apartado, mensualidades y pago final */
interface BloquesPago {
enganche: number
mensualidades: number | null
pagoFinal: number
}
/**
* Cuadra los bloques para que apartado + enganche + mensualidades + pago final
* sea SIEMPRE el total implícito de las fórmulas del Excel (el precio de la
* propuesta), aunque el apartado o un enganche personalizado descuadren las
* fórmulas:
* - ningún bloque queda negativo (nunca se muestra un enganche negativo cuando
* el apartado supera el enganche, ni mensualidades negativas con enganche
* alto);
* - el excedente se recorta de las mensualidades (bloque flexible del
* remanente) y, si no alcanza, del pago final. Nunca se suma por encima del
* precio.
*/
function cuadrarBloques(apartado: number, bloques: BloquesPago): BloquesPago {
const total = apartado + bloques.enganche + (bloques.mensualidades ?? 0) + bloques.pagoFinal
const enganche = Math.max(0, bloques.enganche)
let mensualidades = bloques.mensualidades === null ? null : Math.max(0, bloques.mensualidades)
let pagoFinal = Math.max(0, bloques.pagoFinal)
let excedente = apartado + enganche + (mensualidades ?? 0) + pagoFinal - total
if (excedente > 0 && mensualidades !== null) {
const recorte = Math.min(mensualidades, excedente)
mensualidades -= recorte
excedente -= recorte
}
if (excedente > 0) pagoFinal = Math.max(0, pagoFinal - excedente)
return { enganche, mensualidades, pagoFinal }
}
/** Calcula una propuesta comercial con los parámetros dados */
function calcularPropuestaConfig(
cfg: PropuestaConfig,
@@ -140,18 +188,18 @@ function calcularPropuestaConfig(
? Math.max(ENGANCHE_MINIMO_PCT, enganchePct)
: cfg.pctEnganche
// Enganche: pct sobre su base, neto del apartado (salvo la guarda de SOLANA)
// Enganche BRUTO: pct sobre su base (sin descontar el apartado); la guarda de
// SOLANA lo deja en 0 cuando el % es 0
const baseEnganche = cfg.engancheBase === 'lista' ? precioLista : precioFinal
const enganche =
cfg.engancheCeroSinApartado && pctEnganche === 0
? 0
: pctEnganche * baseEnganche - apartado
const engancheBruto = cfg.engancheCeroSinApartado && pctEnganche === 0 ? 0 : pctEnganche * baseEnganche
// Fórmula del Excel: enganche neto del apartado (puede salir negativo si el
// apartado supera el enganche; cuadrarBloques lo acota a 0)
const engancheExcel = engancheBruto - apartado
const basePagoFinal = cfg.pagoFinalBase === 'lista' ? precioLista : precioFinal
const pagoFinal = cfg.pctPagoFinal * basePagoFinal
const pagoFinalExcel = cfg.pctPagoFinal * basePagoFinal
let mensualidadesTotal: number | null = null
let mensualidad: number | null = null
let mensualidadesExcel: number | null = null
let notaMensualidades: string | undefined
if (cfg.modoMensualidades !== 'ninguna') {
if (n === null) {
@@ -160,23 +208,26 @@ function calcularPropuestaConfig(
} else {
switch (cfg.modoMensualidades) {
case 'remanente':
mensualidadesTotal = precioFinal - apartado - enganche - pagoFinal
mensualidadesExcel = precioFinal - apartado - engancheExcel - pagoFinalExcel
break
case 'remanenteLista':
// Quirk DIAGONAL Infonavit: el Excel usa precioLista aquí, NO precioFinal
mensualidadesTotal = precioLista - pagoFinal - apartado - enganche
mensualidadesExcel = precioLista - pagoFinalExcel - apartado - engancheExcel
break
case 'porcentaje':
mensualidadesTotal = precioFinal * (1 - pctEnganche - cfg.pctPagoFinal)
mensualidadesExcel = precioFinal * (1 - pctEnganche - cfg.pctPagoFinal)
break
}
// Con enganche alto el remanente puede quedar en negativo: se acota a 0
// (ya no hay nada que diferir; el cliente paga de más al inicio)
mensualidadesTotal = Math.max(0, mensualidadesTotal)
mensualidad = mensualidadesTotal / n
}
}
const { enganche, mensualidades, pagoFinal } = cuadrarBloques(apartado, {
enganche: engancheExcel,
mensualidades: mensualidadesExcel,
pagoFinal: pagoFinalExcel,
})
const mensualidad = mensualidades === null || n === null ? null : mensualidades / n
return {
tipo: cfg.tipo,
titulo: cfg.titulo,
@@ -186,7 +237,7 @@ function calcularPropuestaConfig(
enganche,
numMensualidades: n,
mensualidad,
mensualidadesTotal,
mensualidadesTotal: mensualidades,
pagoFinal,
...(notaMensualidades !== undefined ? { notaMensualidades } : {}),
}

View File

@@ -5,6 +5,7 @@ import { compararUnidades } from './comparadorUnidades'
import { APARTADO_DEFAULT, calcularPropuestas, DESCUENTOS_DEFAULT, totalPagado } from './calculations'
import { UNIDADES_SEED } from '../data/unidades'
import { UNIDADES_ATELIER } from '../data/unidades-atelier'
import { DESARROLLOS } from '../data/desarrollos'
import { formatoMoneda } from './formatters'
describe('folioCotizacion', () => {
@@ -106,10 +107,11 @@ describe('compararUnidades', () => {
expect(r.a.precioLista).toBe(diagonal.precioSinAcabados)
})
it('total null cuando el plazo no se puede conocer (sin fecha de entrega)', () => {
it('una unidad sin fecha de entrega usa el plazo por defecto del desarrollo', () => {
const sinFecha = { ...diagonal, fechaEntrega: null }
const r = compararUnidades(sinFecha, diagonal, 'CREDITO_BANCARIO', APARTADO_DEFAULT, true)
expect(r.a.total).toBeNull()
expect(r.a.propuesta.numMensualidades).toBe(DESARROLLOS['DIAGONAL']?.nDefault)
expect(r.a.total).not.toBeNull()
expect(r.b.total).not.toBeNull()
})
})

View File

@@ -20,6 +20,7 @@ const respuesta = (data: unknown, status = 200) =>
const almacenamiento = new Map<string, string>()
beforeEach(() => {
almacenamiento.clear()
window.history.replaceState({}, '', '/')
vi.stubGlobal('localStorage', {
getItem: (k: string) => almacenamiento.get(k) ?? null,
setItem: (k: string, v: string) => void almacenamiento.set(k, v),
@@ -88,4 +89,58 @@ describe('CotizadorPage — barra sticky y guardado local', () => {
})
expect(screen.getByText(/cotización guardada localmente con folio CTE-LOCAL-/)).toBeTruthy()
})
it('no deja cotizar una unidad que ya no está disponible: avisa y bloquea las acciones', async () => {
const vendida = { ...unidad, codigo: 'U-900', status: 'VENDIDO' as const }
const cotizacionVendida = {
id: 'cot-vendida',
folio: 'COT-VENDID',
unidadCodigo: vendida.codigo,
desarrollo: 'DIAGONAL',
clienteNombre: 'Cliente Antiguo',
clienteTelefono: null,
clienteEmail: null,
tipoPropuesta: 'BANCARIO',
acabados: false,
apartado: 20000,
descuento: 0,
precioLista: vendida.precioConAcabados,
precioFinal: 0,
enganche: 0,
numMensualidades: 12,
mensualidad: 1000,
pagoFinal: 0,
createdAt: '2026-09-01T00:00:00.000Z',
}
const fetchMock = vi.fn((input: RequestInfo | URL) => {
const url = String(input)
if (url.includes('/unidades')) return Promise.resolve(respuesta([unidad, vendida]))
if (url.includes('/cotizaciones/mias')) return Promise.resolve(respuesta([cotizacionVendida]))
return Promise.reject(new TypeError(`fetch inesperado: ${url}`))
})
vi.stubGlobal('fetch', fetchMock)
// Prellenado desde Mi día con una unidad que ya se vendió
window.history.replaceState({}, '', `/?unidad=${vendida.codigo}`)
render(
<MemoryRouter>
<CotizadorPage />
</MemoryRouter>,
)
// El prellenado NO la preselecciona y lo dice
expect(await screen.findByText(/ya no está disponible \(estatus: VENDIDO\)/)).toBeTruthy()
// Retomar la cotización vieja de esa unidad: se restaura, pero avisa y
// deja Guardar/Compartir/Generar PDF bloqueados
fireEvent.click(await screen.findByRole('button', { name: /Ver historial/ }))
fireEvent.click(await screen.findByRole('button', { name: 'Retomar' }))
const alerta = await screen.findByRole('alert')
expect(alerta.textContent).toContain(`La unidad ${vendida.codigo} no está disponible`)
expect(screen.getByText(/ya no está disponible \(estatus: VENDIDO\): no se puede cotizar de nuevo/)).toBeTruthy()
for (const nombre of ['Generar PDF', 'Guardar', 'Compartir']) {
expect((screen.getByRole('button', { name: nombre }) as HTMLButtonElement).disabled).toBe(true)
}
})
})

View File

@@ -21,18 +21,22 @@ import { formatoMoneda, formatoFechaLarga } from '../lib/formatters'
import { calcularVigencia } from '../lib/vigencia'
import { folioCotizacion } from '../lib/folio'
import { textoCompartir, urlWhatsApp } from '../lib/compartir'
import { indiceMenorTotal, totalPagado } from '../lib/calculations'
import { indiceMenorTotal, totalPagado, ENGANCHE_MAXIMO_PCT } from '../lib/calculations'
import { LISTA_DESARROLLOS, obtenerDesarrollo } from '../data/desarrollos'
import {
getMisCotizaciones,
guardarCotizacion,
urlPublicaCotizacion,
ErrorApiCotizacion,
TIPO_A_API,
TIPO_DE_API,
type CotizacionGuardada,
} from '../services/api'
import { cargarCotizacionesLocales, guardarCotizacionLocal } from '../services/offline-db'
import type { Cliente } from '../types'
import type { Cliente, Unidad } from '../types'
/** Solo el inventario DISPONIBLE se puede cotizar (apartar/vender bloquea la unidad) */
const esDisponible = (u: Unidad): boolean => (u.status ?? '').trim().toUpperCase() === 'DISPONIBLE'
const esquemaCliente = z.object({
nombre: z.string().min(3, 'El nombre del cliente es requerido (mínimo 3 caracteres)'),
@@ -162,11 +166,21 @@ export default function CotizadorPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Preselecciona la unidad cuando el inventario terminó de cargar
// Preselecciona la unidad cuando el inventario terminó de cargar. Solo
// unidades DISPONIBLES: si la pedida ya se apartó/vendió (o no está en el
// inventario) se avisa y no se preselecciona nada.
useEffect(() => {
if (unidadPendiente === null || cargando) return
const encontrada = unidades.find((u) => u.codigo === unidadPendiente)
if (encontrada) {
if (!encontrada) {
setAviso(
`La unidad ${unidadPendiente} no está en el inventario actual: elige otra unidad disponible.`,
)
} else if (!esDisponible(encontrada)) {
setAviso(
`La unidad ${encontrada.codigo} ya no está disponible (estatus: ${encontrada.status}). Elige otra unidad disponible.`,
)
} else {
const desarrolloUnidad = encontrada.desarrollo ?? 'DIAGONAL'
if (desarrolloUnidad !== desarrollo) setDesarrollo(desarrolloUnidad)
setUnidad(encontrada)
@@ -180,7 +194,9 @@ export default function CotizadorPage() {
const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))]
// Total pagado por el cliente con la propuesta elegida; null si no se puede conocer
const totalActivo = propuestaActiva ? totalPagado(propuestaActiva, apartadoEfectivo) : null
const puedeGenerarPdf = Boolean(unidad) && !errorEnganche && !guardando
// Una unidad apartada/vendida no se cotiza: se avisa y se bloquean las acciones
const unidadNoDisponible = unidad !== null && !esDisponible(unidad)
const puedeGenerarPdf = Boolean(unidad) && !unidadNoDisponible && !errorEnganche && !guardando
// ---------- Preselección por defecto: la propuesta de menor total pagado ----------
// Al cambiar de unidad (o al elegir la primera) se preselecciona la propuesta
@@ -234,6 +250,8 @@ export default function CotizadorPage() {
* Persiste la cotización en el API. Si no responde, la guarda localmente
* (IndexedDB/localStorage) con folio CTE-LOCAL-* y la devuelve igual: el PDF
* siempre sale con folio (sin QR, que solo existe para cotizaciones del servidor).
* Si el servidor RECHAZA la cotización (unidad no disponible, datos inválidos)
* devuelve null: se avisa y no se guarda nada.
*/
const guardarEnApi = async (cliente: Cliente): Promise<CotizacionGuardada | null> => {
if (!unidad || !propuestaActiva) return null
@@ -247,6 +265,7 @@ export default function CotizadorPage() {
acabados,
apartado: apartadoEfectivo,
descuento: propuestaActiva.descuento,
...(numMeses !== null ? { numMensualidades: numMeses } : {}),
...(enganchePct !== null && errorEnganche === null ? { enganchePct: enganchePct / 100 } : {}),
...(oportunidadId !== null ? { oportunidadId } : {}),
})
@@ -256,6 +275,12 @@ export default function CotizadorPage() {
setMisCotizaciones((prev) => [conFolio, ...prev].slice(0, 50))
return conFolio
} catch (err) {
// El servidor contestó y rechazó la cotización: no se guarda ni local
if (err instanceof ErrorApiCotizacion) {
toast.error(err.message)
setAviso(err.message)
return null
}
console.warn('[api] No se pudo guardar en el servidor; se guarda localmente.', err)
const local = cotizacionLocal(cliente)
void guardarCotizacionLocal(local)
@@ -272,17 +297,17 @@ export default function CotizadorPage() {
setGuardando(true)
try {
const guardada = await guardarEnApi(cliente)
let folio: string | undefined
// Rechazada por el servidor (p. ej. la unidad dejó de estar disponible):
// no se genera el PDF
if (!guardada) return
const folio = guardada.folio ?? folioCotizacion(guardada.id)
let qrDataUrl: string | undefined
if (guardada) {
folio = guardada.folio ?? folioCotizacion(guardada.id)
// El QR apunta al resumen público en el servidor; una cotización local no lo tiene
if (!esCotizacionLocal(guardada)) {
qrDataUrl = await QRCode.toDataURL(urlPublicaCotizacion(guardada.id), {
margin: 1,
width: 200,
})
}
// El QR apunta al resumen público en el servidor; una cotización local no lo tiene
if (!esCotizacionLocal(guardada)) {
qrDataUrl = await QRCode.toDataURL(urlPublicaCotizacion(guardada.id), {
margin: 1,
width: 200,
})
}
generarPdf({
unidad,
@@ -293,7 +318,7 @@ export default function CotizadorPage() {
propuestas: propuestaActiva ? [propuestaActiva] : [],
cliente,
desarrollo: params,
...(folio ? { folio } : {}),
folio,
...(qrDataUrl ? { qrDataUrl } : {}),
vigencia: calcularVigencia(),
})
@@ -402,12 +427,17 @@ export default function CotizadorPage() {
apartado: c.apartado,
descuento: c.descuento,
propuestaIndex: idx >= 0 ? idx : 0,
numMensualidades: c.numMensualidades > 0 ? c.numMensualidades : null,
})
setValue('nombre', c.clienteNombre, { shouldValidate: true })
setValue('telefono', c.clienteTelefono ?? '', { shouldValidate: true })
setValue('email', c.clienteEmail ?? '', { shouldValidate: true })
setUltimaGuardada(c)
setAviso(`Cotización ${c.folio ?? ''} retomada.`)
setAviso(
esDisponible(u)
? `Cotización ${c.folio ?? ''} retomada.`
: `Cotización ${c.folio ?? ''} retomada, pero la unidad ${u.codigo} ya no está disponible (estatus: ${u.status}): no se puede cotizar de nuevo.`,
)
}
const handleNueva = () => {
@@ -442,6 +472,17 @@ export default function CotizadorPage() {
</p>
)}
{unidadNoDisponible && unidad && (
<p
role="alert"
className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm font-medium text-red-800"
>
La unidad {unidad.codigo} no está disponible (estatus: {unidad.status}): no se puede
cotizar ni apartar. Elige otra unidad disponible; Guardar, Compartir y Generar PDF
quedan bloqueados.
</p>
)}
<div className="grid items-start gap-6 md:grid-cols-1 lg:grid-cols-[380px_minmax(0,1fr)] lg:gap-8">
{/* Panel de configuración */}
<aside className="space-y-6 lg:sticky lg:top-24">
@@ -549,7 +590,7 @@ export default function CotizadorPage() {
id="enganche"
type="number"
min={2.5}
max={100}
max={ENGANCHE_MAXIMO_PCT}
step={0.5}
placeholder="Según propuesta"
value={enganchePct ?? ''}
@@ -568,7 +609,8 @@ export default function CotizadorPage() {
<p className="mt-1 text-xs text-red-700">{errorEnganche}</p>
) : (
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
Aplica a crédito bancario e Infonavit/Cofinavit; mínimo 2.5%
Aplica a crédito bancario e Infonavit/Cofinavit; rango válido 2.5% a{' '}
{ENGANCHE_MAXIMO_PCT}% (lo que permite el Excel)
{unidad ? ` (${formatoMoneda((precioLista * Math.max(enganchePct ?? 2.5, 2.5)) / 100)})` : ''}
</p>
)}

View File

@@ -87,10 +87,27 @@ export interface GuardarCotizacionPayload {
acabados: boolean
apartado?: number
descuento?: number
/** Mensualidades elegidas por el vendedor (sin él, el API usa el plazo del desarrollo) */
numMensualidades?: number
/** Item del tablero Oportunidades a vincular (botón "Cotizar" de Mi día) */
oportunidadId?: string
}
/**
* Error del API al guardar una cotización (status HTTP + mensaje del servidor).
* Se distingue de los fallos de red: el servidor SÍ contestó y rechazó la
* cotización (p. ej. la unidad ya no está disponible), así que no se guarda.
*/
export class ErrorApiCotizacion extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message)
this.name = 'ErrorApiCotizacion'
}
}
/**
* URL pública de una cotización (destino del QR del PDF). Debe ser ABSOLUTA:
* un QR con ruta relativa ("/api/...") no es navegable al escanearlo desde el
@@ -111,7 +128,11 @@ export async function guardarCotizacion(payload: GuardarCotizacionPayload): Prom
body: JSON.stringify(payload),
}))
manejar401(resp)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
if (!resp.ok) {
// El API explica el rechazo (unidad no disponible, datos inválidos, …)
const cuerpo = (await resp.json().catch(() => null)) as { error?: string } | null
throw new ErrorApiCotizacion(resp.status, cuerpo?.error ?? `No se pudo guardar la cotización (HTTP ${resp.status})`)
}
return (await resp.json()) as CotizacionGuardada
}

View File

@@ -86,6 +86,12 @@ export interface ParametrosDesarrollo {
fuenteDeN: 'fechaEntrega' | 'fijo' | 'porUnidad'
/** N fijo cuando fuenteDeN === 'fijo' (entrega inmediata / term. obra) */
nFijo?: number
/**
* N por defecto del desarrollo: se usa cuando el inventario no trae fecha de
* entrega ni meses para firma. Garantiza que la mensualidad y el total
* siempre se calculen.
*/
nDefault?: number
/** Cuota mensual de mantenimiento (MXN); null => se muestra "PENDIENTE" */
cuotaMantenimiento: number | null
/** Prefijo de la unidad en el PDF ('CASA', 'DEPTO') */