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