feat(cotizador): historial, compartir WhatsApp, folio+QR, comparador de unidades, alertas de inventario

- Mis cotizaciones (guardar con cliente, retomar, nueva) asociadas al vendedor del token
- Compartir: resumen copiable + deep link wa.me; PDF con folio, vigencia y QR
- GET /api/cotizaciones/:id/publico (público, datos mínimos sin PII del cliente)
- Comparador lado a lado de 2 unidades (mismo o distinto desarrollo)
- POST /api/cotizaciones/alertas-inventario: avisa al vendedor si una unidad
  cotizada cambia de estatus (dedup; wire con syncs documentado)
- Fix: labels de propuesta mapeados a los reales del board Oportunidades
- Tests: api 201, cotizador 82
This commit is contained in:
2026-08-28 06:58:10 +00:00
parent bc0f9da8fe
commit 4f31d1c171
24 changed files with 1938 additions and 20 deletions

View File

@@ -1,16 +1,31 @@
import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import QRCode from 'qrcode'
import { useUnidades } from '../hooks/useUnidades'
import { useCotizacion } from '../hooks/useCotizacion'
import { UnitSelector } from '../components/UnitSelector'
import { UnitInfoCard } from '../components/UnitInfoCard'
import { ProposalCard } from '../components/ProposalCard'
import { ComparadorPropuestas } from '../components/ComparadorPropuestas'
import { ComparadorUnidades } from '../components/ComparadorUnidades'
import { MisCotizaciones } from '../components/MisCotizaciones'
import { ModalCliente } from '../components/ModalCliente'
import { generarPdf } from '../lib/pdf'
import { formatoMoneda, formatoFechaLarga } from '../lib/formatters'
import { calcularVigencia } from '../lib/vigencia'
import { folioCotizacion } from '../lib/folio'
import { textoCompartir, urlWhatsApp } from '../lib/compartir'
import { LISTA_DESARROLLOS, obtenerDesarrollo } from '../data/desarrollos'
import {
getMisCotizaciones,
guardarCotizacion,
urlPublicaCotizacion,
TIPO_A_API,
TIPO_DE_API,
type CotizacionGuardada,
} from '../services/api'
import type { Cliente } from '../types'
const esquemaCliente = z.object({
@@ -46,17 +61,50 @@ export default function CotizadorPage() {
apartadoEfectivo,
errorApartado,
setDescuento,
propuestaIndex,
setPropuestaIndex,
precioLista,
numMeses,
propuestas,
restaurar,
nueva,
} = cotizacion
const unidadesDesarrollo = unidades.filter((u) => (u.desarrollo ?? 'DIAGONAL') === desarrollo)
const disponiblesDesarrollo = unidadesDesarrollo.filter((u) => u.status === 'DISPONIBLE').length
// Historial del vendedor + última cotización guardada (para folio/QR/compartir)
const [misCotizaciones, setMisCotizaciones] = useState<CotizacionGuardada[]>([])
const [cargandoMias, setCargandoMias] = useState(true)
const [errorMias, setErrorMias] = useState<string | null>(null)
const [ultimaGuardada, setUltimaGuardada] = useState<CotizacionGuardada | null>(null)
const [modalClienteAbierto, setModalClienteAbierto] = useState(false)
const [guardando, setGuardando] = useState(false)
const [aviso, setAviso] = useState<string | null>(null)
useEffect(() => {
let vivo = true
getMisCotizaciones()
.then((lista) => {
if (vivo) setMisCotizaciones(lista)
})
.catch((err) => {
console.warn('[api] No se pudo cargar el historial de cotizaciones.', err)
if (vivo) setErrorMias('No se pudo cargar el historial (sin conexión al servidor).')
})
.finally(() => {
if (vivo) setCargandoMias(false)
})
return () => {
vivo = false
}
}, [])
const {
register,
watch,
setValue,
reset,
formState: { errors },
} = useForm<FormularioCliente>({
resolver: zodResolver(esquemaCliente),
@@ -65,16 +113,144 @@ export default function CotizadorPage() {
})
const nombreCliente = watch('nombre') ?? ''
const puedeGenerarPdf = Boolean(unidad) && nombreCliente.trim().length >= 3 && !errorApartado
// La propuesta elegida (para guardar/compartir); acotada por si cambia el desarrollo
const propuestaActiva = propuestas[Math.min(propuestaIndex, Math.max(propuestas.length - 1, 0))]
const puedeGenerarPdf = Boolean(unidad) && !errorApartado && !guardando
/** Persiste la cotización en el API; null si no hay conexión (el PDF sale igual, sin folio/QR) */
const guardarEnApi = async (cliente: Cliente): Promise<CotizacionGuardada | null> => {
if (!unidad || !propuestaActiva) return null
try {
const guardada = await guardarCotizacion({
unidadCodigo: unidad.codigo,
clienteNombre: cliente.nombre,
...(cliente.email ? { clienteEmail: cliente.email } : {}),
...(cliente.telefono ? { clienteTelefono: cliente.telefono } : {}),
tipoPropuesta: TIPO_A_API[propuestaActiva.tipo],
acabados,
apartado: apartadoEfectivo,
descuento: propuestaActiva.descuento,
})
const conFolio = { ...guardada, folio: guardada.folio ?? folioCotizacion(guardada.id) }
setUltimaGuardada(conFolio)
setMisCotizaciones((prev) => [conFolio, ...prev].slice(0, 50))
return conFolio
} catch (err) {
console.warn('[api] No se pudo guardar la cotización; el PDF sale sin folio/QR.', err)
setAviso('Sin conexión al servidor: la cotización no se guardó (PDF sin folio/QR).')
return null
}
}
const generarPdfConCliente = async (cliente: Cliente) => {
if (!unidad) return
setGuardando(true)
try {
const guardada = await guardarEnApi(cliente)
let folio: string | undefined
let qrDataUrl: string | undefined
if (guardada) {
folio = guardada.folio ?? folioCotizacion(guardada.id)
qrDataUrl = await QRCode.toDataURL(urlPublicaCotizacion(guardada.id), {
margin: 1,
width: 200,
})
}
generarPdf({
unidad,
acabados,
apartado: apartadoEfectivo,
precioLista,
propuestas,
cliente,
desarrollo: params,
...(folio ? { folio } : {}),
...(qrDataUrl ? { qrDataUrl } : {}),
vigencia: calcularVigencia(),
})
} finally {
setGuardando(false)
}
}
const handleGenerarPdf = () => {
if (!unidad) return
const cliente: Cliente = {
// Sin nombre capturado, pide los datos del cliente en el modal
if (nombreCliente.trim().length < 3) {
setModalClienteAbierto(true)
return
}
void generarPdfConCliente({
nombre: nombreCliente.trim(),
email: watch('email') || undefined,
telefono: watch('telefono') || undefined,
})
}
const handleModalAceptar = (cliente: Cliente) => {
setModalClienteAbierto(false)
setValue('nombre', cliente.nombre, { shouldValidate: true })
if (cliente.telefono) setValue('telefono', cliente.telefono, { shouldValidate: true })
if (cliente.email) setValue('email', cliente.email, { shouldValidate: true })
void generarPdfConCliente(cliente)
}
const handleCompartir = async () => {
if (!unidad || !propuestaActiva) return
const texto = textoCompartir({
unidadCodigo: unidad.codigo,
desarrolloNombre: params.nombre,
propuesta: propuestaActiva,
apartado: apartadoEfectivo,
vigencia: calcularVigencia(),
...(ultimaGuardada
? {
folio: ultimaGuardada.folio ?? folioCotizacion(ultimaGuardada.id),
urlPublica: urlPublicaCotizacion(ultimaGuardada.id),
}
: {}),
})
try {
await navigator.clipboard.writeText(texto)
setAviso('Resumen copiado al portapapeles.')
} catch {
setAviso('No se pudo copiar al portapapeles.')
}
generarPdf({ unidad, acabados, apartado: apartadoEfectivo, precioLista, propuestas, cliente, desarrollo: params })
const telefono = watch('telefono') || ultimaGuardada?.clienteTelefono || undefined
const url = urlWhatsApp(telefono, texto)
if (url) window.open(url, '_blank', 'noopener')
}
const handleRetomar = (c: CotizacionGuardada) => {
const u = unidades.find(
(x) => x.codigo === c.unidadCodigo && (x.desarrollo ?? 'DIAGONAL') === c.desarrollo,
)
if (!u) {
setAviso(`La unidad ${c.unidadCodigo} ya no está en el inventario actual.`)
return
}
const tipo = TIPO_DE_API[c.tipoPropuesta]
const idx = obtenerDesarrollo(c.desarrollo).propuestas.findIndex((p) => p.tipo === tipo)
restaurar({
desarrollo: c.desarrollo,
unidad: u,
acabados: c.acabados,
apartado: c.apartado,
descuento: c.descuento,
propuestaIndex: idx >= 0 ? idx : 0,
})
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.`)
}
const handleNueva = () => {
nueva()
reset({ nombre: '', email: '', telefono: '' })
setUltimaGuardada(null)
setAviso(null)
}
return (
@@ -251,6 +427,16 @@ export default function CotizadorPage() {
</p>
</div>
)}
{unidad && (
<button
type="button"
onClick={handleNueva}
className="w-full border border-neutral-300 bg-white px-5 py-2.5 text-xs font-bold tracking-widest text-neutral-600 uppercase transition-colors duration-150 hover:bg-neutral-100"
>
Nueva cotización
</button>
)}
</section>
{unidad && <UnitInfoCard unidad={unidad} numMeses={numMeses} />}
@@ -258,14 +444,21 @@ export default function CotizadorPage() {
{/* Panel de propuestas */}
<section>
<MisCotizaciones
cotizaciones={misCotizaciones}
cargando={cargandoMias}
error={errorMias}
onRetomar={handleRetomar}
/>
{!unidad ? (
<div className="flex h-64 items-center justify-center border border-dashed border-neutral-300 bg-white">
<div className="mt-16 flex h-64 items-center justify-center border border-dashed border-neutral-300 bg-white">
<p className="text-sm text-neutral-500">
Selecciona una unidad para ver las propuestas comerciales.
</p>
</div>
) : (
<>
<div className="mt-16">
<div className="grid gap-8 md:grid-cols-2">
{propuestas.map((p, i) => (
<ProposalCard
@@ -283,25 +476,67 @@ export default function CotizadorPage() {
propuestas={propuestas}
titulos={propuestas.map((p) => p.titulo)}
/>
<div className="mt-16 flex justify-end">
<div className="mt-16 flex flex-wrap items-center justify-end gap-4">
<div>
<label htmlFor="propuesta-elegida" className={etiquetaCampo}>
Propuesta elegida
</label>
<select
id="propuesta-elegida"
value={Math.min(propuestaIndex, propuestas.length - 1)}
onChange={(e) => setPropuestaIndex(Number(e.target.value))}
className="border border-neutral-300 bg-white px-3 py-2.5 text-sm transition-colors duration-150 focus:border-neutral-900 focus:outline-none"
>
{propuestas.map((p, i) => (
<option key={p.tipo} value={i}>
#{i + 1} {p.titulo}
</option>
))}
</select>
</div>
<button
type="button"
onClick={handleCompartir}
disabled={!puedeGenerarPdf}
className="border border-accent bg-white px-5 py-2.5 text-xs font-bold tracking-widest text-accent uppercase transition-colors duration-150 hover:bg-accent hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
>
Compartir
</button>
<button
type="button"
onClick={handleGenerarPdf}
disabled={!puedeGenerarPdf}
className="bg-accent px-5 py-2.5 text-xs font-bold tracking-widest text-white uppercase transition-colors duration-150 hover:bg-accent-hover disabled:cursor-not-allowed disabled:opacity-40"
>
Generar PDF
{guardando ? 'Guardando…' : 'Guardar y generar PDF'}
</button>
</div>
{!puedeGenerarPdf && (
{aviso && <p className="mt-2 text-right text-xs text-neutral-500">{aviso}</p>}
{!puedeGenerarPdf && !guardando && (
<p className="mt-2 text-right text-xs text-neutral-400">
Selecciona una unidad y captura el nombre del cliente para generar el PDF.
Selecciona una unidad para guardar y generar el PDF.
</p>
)}
</>
</div>
)}
<ComparadorUnidades
unidades={unidades}
tipo={propuestaActiva?.tipo ?? 'CREDITO_BANCARIO'}
apartado={apartadoEfectivo}
acabados={acabados}
/>
</section>
</main>
<ModalCliente
abierto={modalClienteAbierto}
nombreInicial={watch('nombre')}
telefonoInicial={watch('telefono')}
emailInicial={watch('email')}
onAceptar={handleModalAceptar}
onCancelar={() => setModalClienteAbierto(false)}
/>
</div>
)
}