Files
FRASA-CAS/apps/cotizador/src/pages/CotizadorPage.tsx
Consult AS Technology 4f31d1c171 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
2026-08-28 06:58:10 +00:00

543 lines
20 KiB
TypeScript

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({
nombre: z.string().min(3, 'El nombre del cliente es requerido (mínimo 3 caracteres)'),
email: z.union([z.literal(''), z.string().email('Correo electrónico inválido')]).optional(),
telefono: z
.union([z.literal(''), z.string().regex(/^[0-9+\-\s()]{7,15}$/, 'Teléfono inválido')])
.optional(),
})
type FormularioCliente = z.infer<typeof esquemaCliente>
const campoInputBase =
'w-full border bg-white px-3 py-2.5 text-sm transition-colors duration-150 focus:border-neutral-900 focus:outline-none'
const campoInput = `${campoInputBase} border-neutral-300`
const campoInputError = `${campoInputBase} border-red-400`
const etiquetaCampo = 'mb-2 block text-xs font-bold uppercase tracking-widest text-neutral-500'
export default function CotizadorPage() {
const { unidades, cargando, error, usandoSeed } = useUnidades()
const cotizacion = useCotizacion()
const {
desarrollo,
setDesarrollo,
params,
unidad,
setUnidad,
acabados,
setAcabados,
apartado,
setApartado,
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),
mode: 'onChange',
defaultValues: { nombre: '', email: '', telefono: '' },
})
const nombreCliente = watch('nombre') ?? ''
// 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
// 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.')
}
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 (
<div className="min-h-screen bg-white">
<header className="border-b border-neutral-200 bg-white">
<div className="mx-auto flex max-w-7xl flex-wrap items-baseline justify-between gap-2 px-4 py-6">
<h1 className="text-lg tracking-widest text-neutral-900">
<span className="font-bold">FRASA DESARROLLOS</span>{' '}
<span className="font-light text-neutral-500">— Cotizador</span>
</h1>
<div className="text-right">
<p className="text-xs font-bold tracking-widest text-neutral-500 uppercase capitalize">
{formatoFechaLarga(new Date())}
</p>
<p className="mt-1 text-xs text-neutral-500">
Vigencia: hasta el {formatoFechaLarga(calcularVigencia())}
</p>
</div>
</div>
</header>
<main className="mx-auto grid max-w-7xl gap-x-8 gap-y-16 px-4 py-16 lg:grid-cols-[minmax(320px,380px)_1fr]">
{/* Panel de configuración */}
<aside className="space-y-16">
<section className="space-y-8 border border-neutral-200 bg-white p-6">
{error && <p className="text-xs text-red-700">{error}</p>}
{usandoSeed && !cargando && (
<p className="text-xs text-amber-700">Datos de demostración (sin conexión al servidor)</p>
)}
{cargando ? (
<p className="text-sm text-neutral-500">Cargando unidades…</p>
) : (
<>
<div>
<span className={etiquetaCampo}>Desarrollo</span>
<div className="flex flex-wrap border border-neutral-300" role="group">
{LISTA_DESARROLLOS.map((id) => {
const activo = id === desarrollo
const total = unidades.filter((u) => (u.desarrollo ?? 'DIAGONAL') === id).length
const disponibles = unidades.filter(
(u) => (u.desarrollo ?? 'DIAGONAL') === id && u.status === 'DISPONIBLE',
).length
return (
<button
key={id}
type="button"
aria-pressed={activo}
onClick={() => setDesarrollo(id)}
className={`px-4 py-2.5 text-xs font-bold tracking-widest uppercase transition-colors duration-150 ${
activo
? 'bg-accent text-white'
: 'bg-white text-neutral-600 hover:bg-neutral-100'
}`}
>
{obtenerDesarrollo(id).nombre}
<span className={`ml-1 font-normal ${activo ? 'text-white' : 'text-neutral-400'}`}>
{disponibles}/{total}
</span>
</button>
)
})}
</div>
<p className="mt-2 text-xs text-neutral-500">
{unidadesDesarrollo.length} unidades · {disponiblesDesarrollo} disponibles
</p>
</div>
<UnitSelector
unidades={unidadesDesarrollo}
valor={unidad?.codigo ?? ''}
onChange={(codigo) =>
setUnidad(unidadesDesarrollo.find((u) => u.codigo === codigo) ?? null)
}
/>
</>
)}
{!params.precioUnico && (
<div>
<span className={etiquetaCampo}>Cocina y closet</span>
<div className="inline-flex border border-neutral-300" role="group">
{(['SI', 'NO'] as const).map((opcion) => {
const activo = (opcion === 'SI') === acabados
return (
<button
key={opcion}
type="button"
aria-pressed={activo}
onClick={() => setAcabados(opcion === 'SI')}
className={`px-5 py-2.5 text-xs font-bold tracking-widest uppercase transition-colors duration-150 ${
activo
? 'bg-accent text-white'
: 'bg-white text-neutral-600 hover:bg-neutral-100'
}`}
>
{opcion}
</button>
)
})}
</div>
</div>
)}
<div>
<label htmlFor="apartado" className={etiquetaCampo}>
Monto de apartado (MXN)
</label>
<input
id="apartado"
type="number"
min={0}
step={1000}
value={apartado}
onChange={(e) => {
const v = Number(e.target.value)
setApartado(Number.isFinite(v) && v >= 0 ? v : 0)
}}
className={errorApartado ? campoInputError : campoInput}
/>
{errorApartado ? (
<p className="mt-1 text-xs text-red-700">{errorApartado}</p>
) : (
<p className="mt-1 text-xs text-neutral-500">{formatoMoneda(apartado)}</p>
)}
</div>
<div>
<label htmlFor="nombre" className={etiquetaCampo}>
Nombre del cliente *
</label>
<input
id="nombre"
type="text"
className={errors.nombre ? campoInputError : campoInput}
{...register('nombre')}
/>
{errors.nombre && <p className="mt-1 text-xs text-red-700">{errors.nombre.message}</p>}
</div>
<div>
<label htmlFor="email" className={etiquetaCampo}>
Correo electrónico (opcional)
</label>
<input
id="email"
type="email"
className={errors.email ? campoInputError : campoInput}
{...register('email')}
/>
{errors.email && <p className="mt-1 text-xs text-red-700">{errors.email.message}</p>}
</div>
<div>
<label htmlFor="telefono" className={etiquetaCampo}>
Teléfono (opcional)
</label>
<input
id="telefono"
type="tel"
className={errors.telefono ? campoInputError : campoInput}
{...register('telefono')}
/>
{errors.telefono && (
<p className="mt-1 text-xs text-red-700">{errors.telefono.message}</p>
)}
</div>
{unidad && (
<div className="border-t border-neutral-200 pt-8">
<p className="text-xs font-bold tracking-widest text-neutral-500 uppercase">
Precio de lista
</p>
<p className="mt-2 text-3xl font-light text-neutral-900">
{formatoMoneda(precioLista)}
</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} />}
</aside>
{/* Panel de propuestas */}
<section>
<MisCotizaciones
cotizaciones={misCotizaciones}
cargando={cargandoMias}
error={errorMias}
onRetomar={handleRetomar}
/>
{!unidad ? (
<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
key={p.tipo}
numero={i + 1}
titulo={p.titulo}
propuesta={p}
onDescuentoChange={(v) => setDescuento(params.propuestas[i].claveDescuento, v)}
/>
))}
</div>
<ComparadorPropuestas
precioLista={precioLista}
apartado={apartadoEfectivo}
propuestas={propuestas}
titulos={propuestas.map((p) => p.titulo)}
/>
<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"
>
{guardando ? 'Guardando…' : 'Guardar y generar PDF'}
</button>
</div>
{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 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>
)
}