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 DarkModeToggle from '../components/DarkModeToggle' import { SkeletonList } from "../components/Skeleton" import toast from "react-hot-toast" import { UnitSelector } from '../components/UnitSelector' import { UnitInfoCard } from '../components/UnitInfoCard' import { EsquemaPanel } from '../components/EsquemaPanel' 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 { desarrolloEfectivo, idsCotizables } from '../lib/reglas' import { getMisCotizaciones, getCotizacion, guardarCotizacion, urlPublicaCotizacion, ErrorApiCotizacion, type CotizacionGuardada, } from '../services/api' import { cargarCotizacionesLocales, guardarCotizacionLocal } from '../services/offline-db' import type { Cliente, Unidad } from '../types' /** Solo el inventario DISPONIBLE se puede cotizar (apartar/vender bloquea la unidad) */ const esDisponible = (u: Unidad): boolean => (u.status ?? '').trim().toUpperCase() === 'DISPONIBLE' /** Una cotización guardada ya vive en el esquema único (filas nuevas) */ const esEsquemaNuevo = (c: CotizacionGuardada): boolean => c.esquema === 'FRASA_2026' && c.saldoFirma != null 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 dark:bg-[#0F172A] dark:border-[#334155] dark:text-[#E2E8F0]' const campoInput = `${campoInputBase} border-[#D1D5DB]` const campoInputError = `${campoInputBase} border-red-400` const etiquetaCampo = 'mb-2 block text-xs font-medium text-[#4B5563] dark:text-[#CBD5E1]' export default function CotizadorPage() { const { unidades, cargando, error, usandoSeed, reglas } = useUnidades() const cotizacion = useCotizacion(reglas) const { desarrollo, setDesarrollo, params, unidad, setUnidad, acabados, setAcabados, apartado, setApartado, apartadoEfectivo, numMensualidades, setNumMensualidades, escrituracionDiferida, setEscrituracionDiferida, precioLista, resultado, numMeses, restaurar, nueva, } = cotizacion /** Desarrollos que se ofrecen: los activos en las reglas del admin */ const desarrollos = idsCotizables(reglas) 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) // true cuando el modal de cliente se abrió desde "Guardar" (sin generar PDF) const [guardarSinPdf, setGuardarSinPdf] = 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) // Retomar una cotización llegada por URL (/quote/?cotizacion=, desde la // búsqueda global): se aplica cuando termina de cargar inventario+reglas const [cotizacionPendiente, setCotizacionPendiente] = useState(null) // Si el desarrollo activo deja de ofrecerse (lo desactivó el admin), se cae al // primero disponible para no dejar el cotizador en un desarrollo muerto. useEffect(() => { if (desarrollos.includes(desarrollo)) return const alterno = desarrollos[0] if (alterno === undefined) return setDesarrollo(alterno) setAviso( `El desarrollo ${desarrollo} ya no está disponible en el cotizador; se cambió a ${alterno}.`, ) // eslint-disable-next-line react-hooks/exhaustive-deps }, [desarrollos, desarrollo]) useEffect(() => { let vivo = true /** Combina servidor + locales, sin duplicados, más recientes primero */ const mezclar = (principal: CotizacionGuardada[], extra: CotizacionGuardada[]) => { const ids = new Set(principal.map((c) => c.id)) const combinadas = [...principal, ...extra.filter((c) => !ids.has(c.id))] return combinadas .sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)) .slice(0, 50) } // Cotizaciones guardadas localmente cuando el API no respondió void cargarCotizacionesLocales().then((locales) => { if (!vivo || locales.length === 0) return setMisCotizaciones((prev) => mezclar(prev, locales as CotizacionGuardada[])) }) getMisCotizaciones() .then((lista) => { if (vivo) setMisCotizaciones((prev) => mezclar(lista, prev)) }) .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) { if (desarrollos.includes(desarrolloParam)) setDesarrollo(desarrolloParam) else setAviso( `El desarrollo ${desarrolloParam} no está disponible en el cotizador. Elige un desarrollo de la lista.`, ) } if (unidadParam) setUnidadPendiente(unidadParam) if (opId) setOportunidadId(opId) const cotizacionParam = qs.get('cotizacion') if (cotizacionParam) setCotizacionPendiente(cotizacionParam) // 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. Solo // unidades DISPONIBLES: si la pedida ya se apartó/vendió (o no está en el // inventario) se avisa y no se preselecciona nada. useEffect(() => { if (unidadPendiente === null || cargando) return const encontrada = unidades.find((u) => u.codigo === unidadPendiente) if (!encontrada) { setAviso( `La unidad ${unidadPendiente} no está en el inventario actual: elige otra unidad disponible.`, ) } else if (!esDisponible(encontrada)) { setAviso( `La unidad ${encontrada.codigo} ya no está disponible (estatus: ${encontrada.status}). Elige otra unidad disponible.`, ) } else if (!desarrollos.includes(encontrada.desarrollo ?? 'DIAGONAL')) { setAviso( `El desarrollo de la unidad ${encontrada.codigo} no está disponible en el cotizador: elige otra unidad disponible.`, ) } else { 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]) // Retoma la cotización pedida por URL cuando el inventario ya cargó useEffect(() => { if (cotizacionPendiente === null || cargando) return const id = cotizacionPendiente setCotizacionPendiente(null) void getCotizacion(id) .then((c) => { if (c) handleRetomar(c) else setAviso(`No se encontró la cotización entre las tuyas (${id}).`) }) .catch(() => setAviso('No se pudo cargar la cotización de la liga.')) // eslint-disable-next-line react-hooks/exhaustive-deps }, [cotizacionPendiente, cargando, unidades]) // Una unidad apartada/vendida no se cotiza: se avisa y se bloquean las acciones const unidadNoDisponible = unidad !== null && !esDisponible(unidad) const puedeGenerarPdf = Boolean(unidad) && !unidadNoDisponible && !guardando /** true cuando la cotización vive solo en este dispositivo (el API no respondió) */ const esCotizacionLocal = (c: CotizacionGuardada): boolean => c.id.startsWith('local-') /** Instantánea local de la cotización con folio CTE-LOCAL- */ const cotizacionLocal = (cliente: Cliente): CotizacionGuardada => { const timestamp = Date.now() if (!unidad || !resultado) throw new Error('No hay esquema para guardar localmente') return { id: `local-${timestamp}`, folio: `CTE-LOCAL-${timestamp}`, unidadCodigo: unidad.codigo, desarrollo, clienteNombre: cliente.nombre, clienteTelefono: cliente.telefono ?? null, clienteEmail: cliente.email ?? null, esquema: 'FRASA_2026', tipoPropuesta: 'FRASA_2026', acabados, apartado: apartadoEfectivo, descuento: 0, precioLista, precioFinal: precioLista, saldoFirma: resultado.saldoFirma, engancheDiferible: resultado.enganche, escrituracion: resultado.escrituracion, escrituracionDiferida, enganche: resultado.enganche, numMensualidades, mensualidad: resultado.mensualidad, pagoFinal: resultado.pagoUnicoFirma, createdAt: new Date(timestamp).toISOString(), } } /** * Persiste la cotización en el API. Si no responde, la guarda localmente * (IndexedDB/localStorage) con folio CTE-LOCAL-* y la devuelve igual: el PDF * siempre sale con folio (sin QR, que solo existe para cotizaciones del servidor). * Si el servidor RECHAZA la cotización (unidad no disponible, datos inválidos) * devuelve null: se avisa y no se guarda nada. */ const guardarEnApi = async (cliente: Cliente): Promise => { if (!unidad || !resultado) return null try { const guardada = await guardarCotizacion({ unidadCodigo: unidad.codigo, clienteNombre: cliente.nombre, ...(cliente.email ? { clienteEmail: cliente.email } : {}), ...(cliente.telefono ? { clienteTelefono: cliente.telefono } : {}), acabados, apartado: apartadoEfectivo, numMensualidades, escrituracionDiferida, ...(oportunidadId !== null ? { oportunidadId } : {}), }) const conFolio = { ...guardada, folio: guardada.folio ?? folioCotizacion(guardada.id) } setUltimaGuardada(conFolio) toast.success('Cotización guardada correctamente') setMisCotizaciones((prev) => [conFolio, ...prev].slice(0, 50)) return conFolio } catch (err) { // El servidor contestó y rechazó la cotización: no se guarda ni local if (err instanceof ErrorApiCotizacion) { toast.error(err.message) setAviso(err.message) return null } console.warn('[api] No se pudo guardar en el servidor; se guarda localmente.', err) const local = cotizacionLocal(cliente) void guardarCotizacionLocal(local) setUltimaGuardada(local) setMisCotizaciones((prev) => [local, ...prev].slice(0, 50)) toast('Cotización guardada en este dispositivo (sin conexión al servidor)') setAviso(`Sin conexión al servidor: cotización guardada localmente con folio ${local.folio}.`) return local } } const generarPdfConCliente = async (cliente: Cliente) => { if (!unidad || !resultado) return setGuardando(true) try { const guardada = await guardarEnApi(cliente) // Rechazada por el servidor (p. ej. la unidad dejó de estar disponible): // no se genera el PDF if (!guardada) return const folio = guardada.folio ?? folioCotizacion(guardada.id) let qrDataUrl: string | undefined // El QR apunta al resumen público en el servidor; una cotización local no lo tiene if (!esCotizacionLocal(guardada)) { qrDataUrl = await QRCode.toDataURL(urlPublicaCotizacion(guardada.id), { margin: 1, width: 200, }) } generarPdf({ unidad, acabados, apartado: apartadoEfectivo, precioLista, // El PDF imprime SOLO el esquema único FRASA 2026 esquema: resultado, cliente, desarrollo: params, 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) { setGuardarSinPdf(false) setModalClienteAbierto(true) return } void generarPdfConCliente({ nombre: nombreCliente.trim(), email: watch('email') || undefined, telefono: watch('telefono') || undefined, }) } /** Guarda solo (sin PDF); con el flujo de modal si falta el nombre del cliente */ const guardarSolo = async (cliente: Cliente) => { if (!unidad) return setGuardando(true) try { await guardarEnApi(cliente) } finally { setGuardando(false) } } const handleGuardar = () => { if (!unidad) return if (nombreCliente.trim().length < 3) { setGuardarSinPdf(true) setModalClienteAbierto(true) return } void guardarSolo({ 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 }) if (guardarSinPdf) { setGuardarSinPdf(false) void guardarSolo(cliente) } else { void generarPdfConCliente(cliente) } } const handleCompartir = async () => { if (!unidad || !resultado) return const texto = textoCompartir({ unidadCodigo: unidad.codigo, desarrolloNombre: params.nombre, esquema: resultado, apartado: apartadoEfectivo, vigencia: calcularVigencia(), ...(ultimaGuardada ? { folio: ultimaGuardada.folio ?? folioCotizacion(ultimaGuardada.id), // La URL pública solo existe si la cotización vive en el servidor ...(esCotizacionLocal(ultimaGuardada) ? {} : { 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 nueva = esEsquemaNuevo(c) restaurar({ desarrollo: c.desarrollo, unidad: u, acabados: c.acabados, apartado: c.apartado, // Las cotizaciones del esquema anterior retoman con los defaults del // nuevo esquema (14 mensualidades, escrituración a la firma) ...(nueva ? { numMensualidades: c.numMensualidades > 0 ? c.numMensualidades : undefined, escrituracionDiferida: c.escrituracionDiferida, } : {}), }) setValue('nombre', c.clienteNombre, { shouldValidate: true }) setValue('telefono', c.clienteTelefono ?? '', { shouldValidate: true }) setValue('email', c.clienteEmail ?? '', { shouldValidate: true }) setUltimaGuardada(c) const avisoEsquema = nueva ? '' : ` Usa el esquema anterior (${c.tipoPropuesta}); se cargó con el esquema FRASA 2026 y sus valores por defecto.` setAviso( esDisponible(u) ? `Cotización ${c.folio ?? ''} retomada.${avisoEsquema}` : `Cotización ${c.folio ?? ''} retomada, pero la unidad ${u.codigo} ya no está disponible (estatus: ${u.status}): no se puede cotizar de nuevo.${avisoEsquema}`, ) } 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 ligada a su oportunidad.

)} {unidadNoDisponible && unidad && (

La unidad {unidad.codigo} no está disponible (estatus: {unidad.status}): no se puede cotizar ni apartar. Elige otra unidad disponible; Guardar, Compartir y Generar PDF quedan bloqueados.

)}
{/* Panel de configuración */} {/* Panel del esquema único FRASA 2026 */}
{!unidad || !resultado ? (

Selecciona una unidad para ver el esquema de cotización.

) : ( )}
{/* Secciones de ancho completo */}
{/* Barra fija de cierre: resumen del esquema + acciones, siempre visibles */}
{resultado ? ( Esquema FRASA 2026 · Hoy {formatoMoneda(resultado.apartado)} · A la firma{' '} {formatoMoneda(resultado.pagoUnicoFirma)} · {resultado.numMensualidades} ×{' '} {formatoMoneda(resultado.mensualidad)} ) : (

Selecciona una unidad para ver el esquema, guardar y generar el PDF.

)} {aviso && (

{aviso}

)}
setModalClienteAbierto(false)} />
) }