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 BannerModoLocal from '../components/BannerModoLocal' 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 { BuscadorCliente } from '../components/BuscadorCliente' 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 const campoInputBase = 'w-full rounded-lg border bg-white px-3 py-2.5 text-sm transition-colors focus:border-accent focus:ring-2 focus:ring-accent/20 focus:outline-none' const campoInput = `${campoInputBase} border-[#D1D5DB]` const campoInputError = `${campoInputBase} border-red-400` const etiquetaCampo = 'mb-2 block text-xs font-medium text-[#4B5563]' 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, enganchePct, setEnganchePct, errorEnganche, 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([]) const [cargandoMias, setCargandoMias] = useState(true) const [errorMias, setErrorMias] = useState(null) const [ultimaGuardada, setUltimaGuardada] = useState(null) const [modalClienteAbierto, setModalClienteAbierto] = useState(false) const [guardando, setGuardando] = useState(false) const [aviso, setAviso] = useState(null) // Prefill desde Mi día (botón "Cotizar" de una oportunidad): el id queda // ligado a la cotización al guardar; la unidad se preselecciona al cargar const [oportunidadId, setOportunidadId] = useState(null) const [unidadPendiente, setUnidadPendiente] = useState(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({ resolver: zodResolver(esquemaCliente), mode: 'onChange', defaultValues: { nombre: '', email: '', telefono: '' }, }) const nombreCliente = watch('nombre') ?? '' // Prefill desde la URL (botón "Cotizar" de Mi día): cliente, contacto, // desarrollo, unidad y el id de la oportunidad a vincular al guardar useEffect(() => { const qs = new URLSearchParams(window.location.search) const cliente = qs.get('cliente') const telefono = qs.get('telefono') const email = qs.get('email') const desarrolloParam = qs.get('desarrollo') const unidadParam = qs.get('unidad') const opId = qs.get('oportunidadId') if (cliente) setValue('nombre', cliente, { shouldValidate: true }) if (telefono) setValue('telefono', telefono, { shouldValidate: true }) if (email) setValue('email', email, { shouldValidate: true }) if (desarrolloParam && LISTA_DESARROLLOS.includes(desarrolloParam)) setDesarrollo(desarrolloParam) if (unidadParam) setUnidadPendiente(unidadParam) if (opId) setOportunidadId(opId) // Solo al montar: es el punto de entrada desde Mi día // eslint-disable-next-line react-hooks/exhaustive-deps }, []) // Preselecciona la unidad cuando el inventario terminó de cargar useEffect(() => { if (unidadPendiente === null || cargando) return const encontrada = unidades.find((u) => u.codigo === unidadPendiente) if (encontrada) { const desarrolloUnidad = encontrada.desarrollo ?? 'DIAGONAL' if (desarrolloUnidad !== desarrollo) setDesarrollo(desarrolloUnidad) setUnidad(encontrada) setAviso(`Unidad ${encontrada.codigo} preseleccionada desde Mi día.`) } setUnidadPendiente(null) // eslint-disable-next-line react-hooks/exhaustive-deps }, [unidades, cargando, unidadPendiente]) // 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 && !errorEnganche && !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 => { 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, ...(enganchePct !== null && errorEnganche === null ? { enganchePct: enganchePct / 100 } : {}), ...(oportunidadId !== null ? { oportunidadId } : {}), }) 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) setOportunidadId(null) // la nueva cotización ya no va ligada a la oportunidad anterior setAviso(null) } return (
{/* Encabezado de página */}

Cotizador

{formatoFechaLarga(new Date())}

Vigencia: hasta el {formatoFechaLarga(calcularVigencia())}

{oportunidadId !== null && nombreCliente.trim() !== '' && (

Cotizando para {nombreCliente} — al guardar, la cotización queda vinculada a su oportunidad en Monday.

)}
{/* Panel de configuración */} {/* Panel de propuestas */}
{!unidad ? (

Selecciona una unidad para ver las propuestas comerciales.

) : (
{propuestas.map((p, i) => ( ))}
p.titulo)} />
{aviso &&

{aviso}

} {!puedeGenerarPdf && !guardando && (

Selecciona una unidad para guardar y generar el PDF.

)}
)}
{/* Secciones de ancho completo */}
setModalClienteAbierto(false)} />
) }