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:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user