- 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
543 lines
20 KiB
TypeScript
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>
|
|
)
|
|
}
|