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

@@ -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>
)}