import { useEffect, useRef, useState, type ReactNode } from 'react' import AppHeader from '../components/AppHeader' import { SkeletonCard } from "../components/Skeleton" import toast from "react-hot-toast" import BannerModoLocal from '../components/BannerModoLocal' import Stepper from '../components/Stepper' import DatosGeneralesForm from '../forms/DatosGeneralesForm' import EmpleoForm from '../forms/EmpleoForm' import EconomicosForm from '../forms/EconomicosForm' import CoacreditadoForm from '../forms/CoacreditadoForm' import ReferenciasForm from '../forms/ReferenciasForm' import ViviendaCreditoForm from '../forms/ViviendaCreditoForm' import DocumentosForm from '../forms/DocumentosForm' import FirmasForm from '../forms/FirmasForm' import { useAltaCliente, PASOS } from '../hooks/useAltaCliente' import { actualizarProgreso, completarProgreso, crearAltaCliente, eliminarBorrador, generarLigaRemota, guardarBorrador, iniciarProgreso, listarBorradores, obtenerBorrador, obtenerCotizacion, obtenerPrefillApartado, subirDocumentos, type BorradorResumen, type LeadResumen, } from '../services/api' import { cotizacionAPrellenado } from '../lib/cotizacionAlta' import { apartadoAPrellenado } from '../lib/apartadoAlta' import { leadAPrellenado } from '../lib/leadAlta' import BusquedaLead from '../components/BusquedaLead' import AltaAfluenciaForm from '../components/AltaAfluenciaForm' /** Lee ?cotizacion= del query string o del hash (#cotizacion=) */ function paramCotizacion(): string | null { const query = new URLSearchParams(window.location.search).get('cotizacion') if (query) return query const hash = window.location.hash.replace(/^#/, '') return new URLSearchParams(hash).get('cotizacion') } /** * Lee ?afluencia= + prefill (nombre/telefono/email/desarrollo/ * modelo) — botón "Afluencia" de Mis oportunidades en Mi día. */ function paramsAfluencia(): { oportunidadId: string; valores: Record } | null { const qs = new URLSearchParams(window.location.search) const id = qs.get('afluencia') if (!id) return null const valores: Record = {} for (const k of ['nombre', 'telefono', 'email', 'desarrollo', 'modelo', 'origen']) { const v = qs.get(k) if (v) valores[k] = v } return { oportunidadId: id, valores } } /** Lee ?apartado= — botón "Alta Enkontrol" de Mis apartados en Mi día */ function paramApartado(): string | null { return new URLSearchParams(window.location.search).get('apartado') } const formatoFechaHora = (iso: string): string => new Date(iso).toLocaleString('es-MX', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) /** Tarjeta del paso actual del wizard (el stepper queda fuera, a la izquierda en lg) */ function TarjetaPaso({ children }: { children: ReactNode }) { return
{children}
} /** Cuadro con las iniciales de la opción (icono de la tarjeta de entrada) */ function IconoIniciales({ iniciales }: { iniciales: string }) { return ( ) } /** Flecha del CTA de las tarjetas de entrada */ function IconoFlecha() { return ( ) } export default function AltaClientePage() { const { paso, siguiente, anterior, irAPaso, datos, alta, actualizar, restaurar, reiniciar, agregarDocumento, quitarDocumento, } = useAltaCliente() const [registro, setRegistro] = useState('') const [sinConexion, setSinConexion] = useState(false) const [errorValidacion, setErrorValidacion] = useState('') // ---------- Borradores ---------- type Pantalla = 'cargando' | 'entrada' | 'picker' | 'afluencia' | 'wizard' const [pantalla, setPantalla] = useState('cargando') const [borradores, setBorradores] = useState([]) const [borradorId, setBorradorId] = useState(null) const [avisoBorrador, setAvisoBorrador] = useState('') const [avisoProgreso, setAvisoProgreso] = useState('') const [cargoDeBorrador, setCargoDeBorrador] = useState(false) const [errorCarga, setErrorCarga] = useState('') // ---------- Liga remota ---------- const [ligaUrl, setLigaUrl] = useState('') const [ligaCopiada, setLigaCopiada] = useState(false) const [errorLiga, setErrorLiga] = useState('') // Afluencia prellenada desde Mis oportunidades (Mi día): actualiza la // oportunidad existente en vez de crear una nueva const [afluenciaPrevia, setAfluenciaPrevia] = useState<{ oportunidadId: string valores: Record } | null>(null) // Al entrar: ?apartado= prellena el alta con todo lo recolectado del // apartado y salta al wizard ligado a ese item; ?afluencia= abre el // formulario de afluencia prellenado; ?cotizacion= prellena el alta; // si no, la entrada ofrece Alta Afluencia (captura ligera) y Alta de Cliente useEffect(() => { const apartadoId = paramApartado() if (apartadoId) { obtenerPrefillApartado(apartadoId) .then((pre) => { const p = apartadoAPrellenado(pre) actualizar('datosGenerales', { ...datos.datosGenerales, ...p.datosGenerales }) actualizar('viviendaCredito', { ...datos.viviendaCredito, ...p.viviendaCredito }) actualizar('apartadoMondayId', apartadoId) if (p.leadMondayId) actualizar('leadMondayId', p.leadMondayId) }) .catch(() => setErrorCarga('No se pudo cargar el apartado para prellenar el alta.')) .finally(() => setPantalla('wizard')) return } const afl = paramsAfluencia() if (afl) { setAfluenciaPrevia(afl) setPantalla('afluencia') return } const cotizacionId = paramCotizacion() if (cotizacionId) { obtenerCotizacion(cotizacionId) .then((c) => { const pre = cotizacionAPrellenado(c) actualizar('datosGenerales', { ...datos.datosGenerales, ...pre.datosGenerales }) actualizar('viviendaCredito', { ...datos.viviendaCredito, ...pre.viviendaCredito }) }) .catch(() => setErrorCarga('No se pudo cargar la cotización para prellenar el alta.')) .finally(() => setPantalla('wizard')) return } listarBorradores() .then((lista) => { setBorradores(lista) setPantalla('entrada') }) // Sin conexión: la entrada no depende de los borradores y conserva la // opción de afluencia (el alta de cliente funcionará cuando vuelva la red) .catch(() => setPantalla('entrada')) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) const continuarBorrador = async (id: string) => { try { const b = await obtenerBorrador(id) restaurar(JSON.parse(b.datosJson) as typeof datos, b.paso) setBorradorId(b.id) setCargoDeBorrador(true) setPantalla('wizard') } catch { setErrorCarga('No se pudo abrir el borrador.') } } const borrarBorrador = async (id: string) => { try { await eliminarBorrador(id) // Al borrar el último borrador el picker sigue siendo la pantalla de // entrada (ofrece "Empezar nuevo" y la búsqueda de leads). setBorradores((prev) => prev.filter((b) => b.id !== id)) } catch { setErrorCarga('No se pudo eliminar el borrador.') } } const empezarNuevo = () => { reiniciar() setBorradorId(null) setCargoDeBorrador(false) setPantalla('wizard') } // Alta nueva prellenada desde un lead de Monday (nombre, email, celular) const empezarDesdeLead = (lead: LeadResumen) => { reiniciar() setBorradorId(null) setCargoDeBorrador(false) actualizar('datosGenerales', { ...datos.datosGenerales, ...leadAPrellenado(lead) }) actualizar('leadMondayId', lead.id) setPantalla('wizard') } const guardar = async () => { try { const b = await guardarBorrador({ ...(borradorId ? { id: borradorId } : {}), paso, datos }) toast.success("Borrador guardado") setBorradorId(b.id) setAvisoBorrador( `Borrador guardado ${new Date().toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' })}`, ) } catch { setAvisoBorrador('No se pudo guardar el borrador') } } // ---------- Guardado progresivo (borrador local + Contacto en Monday) ---------- // Guard in-flight: con clicks rápidos se omite el sync en curso; el siguiente // avance manda el estado completo (el PATCH es idempotente). const syncEnCurso = useRef(false) const syncProgreso = async (datosSync: typeof datos) => { if (syncEnCurso.current) return if (datosSync.datosGenerales.nombreCompleto.trim() === '') return syncEnCurso.current = true try { if (datosSync.progresoId) { await actualizarProgreso(datosSync.progresoId, datosSync) } else { const r = await iniciarProgreso({ datosGenerales: datosSync.datosGenerales, ...(datosSync.leadMondayId ? { leadMondayId: datosSync.leadMondayId } : {}), }) actualizar('progresoId', r.id) } setAvisoProgreso('') } catch (err) { console.warn('[alta] no se pudo sincronizar el avance con el servidor', err) setAvisoProgreso('El avance quedó guardado local; no se pudo subir a Monday') } finally { syncEnCurso.current = false } } /** Al avanzar de paso: guarda el borrador en silencio y sube el avance a Monday */ const guardarAvance = (datosNuevos: typeof datos, pasoDestino: number) => { guardarBorrador({ ...(borradorId ? { id: borradorId } : {}), paso: pasoDestino, datos: datosNuevos }) .then((b) => { setBorradorId(b.id) setAvisoBorrador( `Borrador guardado ${new Date().toLocaleTimeString('es-MX', { hour: '2-digit', minute: '2-digit' })}`, ) }) .catch(() => setAvisoBorrador('No se pudo guardar el borrador')) void syncProgreso(datosNuevos) } // Registra el alta en el API al llegar al paso de firmas; si el API no // responde, el flujo continúa solo local (los PDFs se generan igual). Con // guardado progresivo activo se cierra por el endpoint de progreso. const registrarAlta = async () => { const res = datos.progresoId ? await completarProgreso(datos.progresoId, alta) : await crearAltaCliente(alta) toast.success("Alta de cliente completada") if (res.ok) { await subirDocumentos(res.id, alta) setRegistro(res.id) setSinConexion(false) setErrorValidacion('') // El alta ya quedó registrada: el borrador usado ya no hace falta if (borradorId) { eliminarBorrador(borradorId).catch(() => undefined) setBorradorId(null) } } else if (res.motivo === 'validacion') { setRegistro('') setSinConexion(false) setErrorValidacion(res.detalle ?? 'Datos incompletos o inválidos') } else { setRegistro('') setSinConexion(true) setErrorValidacion('') } } // Redirigir a Mi día después de completar el alta useEffect(() => { if (!registro) return const timer = setTimeout(() => { window.location.href = '/hoy/' }, 2000) return () => clearTimeout(timer) }, [registro]) // Genera la liga pública y copia al portapapeles el texto listo para WhatsApp const generarLiga = async () => { setErrorLiga('') setLigaCopiada(false) try { const liga = await generarLigaRemota(registro) const urlAbsoluta = `${window.location.origin}${liga.url}` setLigaUrl(urlAbsoluta) const texto = `Hola ${alta.datosGenerales.nombreCompleto}, te comparto la liga para firmar tu expediente y subir tus documentos (vigencia 72 h): ${urlAbsoluta}` await navigator.clipboard.writeText(texto) setLigaCopiada(true) } catch { setErrorLiga('No se pudo generar la liga. Intenta de nuevo.') } } if (pantalla === 'cargando') { return (
) } if (pantalla === 'entrada') { return (

Elige cómo registrar al interesado: captura ligera de afluencia o formato completo de alta.

) } if (pantalla === 'afluencia') { return (

Captura rápida del interesado; entra al embudo de ventas.

{ setAfluenciaPrevia(null) setPantalla('entrada') }} onPasarAAltaCliente={empezarDesdeLead} />
) } if (pantalla === 'picker') { return (
{borradores.length > 0 && ( <>

Tienes {borradores.length} borrador{borradores.length === 1 ? '' : 'es'} guardado {borradores.length === 1 ? '' : 's'}

    {borradores.map((b) => (
  • {b.nombre}

    Paso {b.paso + 1} de {PASOS.length} · {formatoFechaHora(b.updatedAt)}

  • ))}
)} {errorCarga &&

{errorCarga}

}

Desde un lead de Monday

Empieza un alta nueva con el nombre, email y celular del lead ya capturados.

) } return (
{avisoBorrador && ( {avisoBorrador} )} {avisoProgreso && ( {avisoProgreso} )} } />
{errorCarga && (

{errorCarga}

)} {datos.apartadoMondayId && (

Alta ligada al apartado del cliente en Monday — al completarla se actualiza ese item (no se duplica).

)} {paso === 0 && ( { actualizar('datosGenerales', v) siguiente() guardarAvance({ ...datos, datosGenerales: v }, paso + 1) }} /> )} {paso === 1 && ( { actualizar('empleo', v) siguiente() guardarAvance({ ...datos, empleo: v }, paso + 1) }} /> )} {paso === 2 && ( { actualizar('economicos', v) siguiente() guardarAvance({ ...datos, economicos: v }, paso + 1) }} /> )} {paso === 3 && ( { actualizar('coacreditado', v) siguiente() guardarAvance({ ...datos, coacreditado: v }, paso + 1) }} /> )} {paso === 4 && ( { actualizar('referencias', v) siguiente() guardarAvance({ ...datos, referencias: v }, paso + 1) }} /> )} {paso === 5 && ( { actualizar('viviendaCredito', v) siguiente() guardarAvance({ ...datos, viviendaCredito: v }, paso + 1) }} /> )} {paso === 6 && ( <> {cargoDeBorrador && (

Retomaste un borrador: los archivos de los documentos no se guardan en el borrador, vuelve a adjuntarlos.

)} actualizar('datosGenerales', { ...datos.datosGenerales, ...campos }) } onAnterior={anterior} onSiguiente={() => { void registrarAlta() siguiente() guardarAvance(datos, paso + 1) }} /> )} {paso === 7 && ( <> {registro && (

Alta registrada en el servidor: {registro}

)} {registro && (

Redirigiendo a Mi día en unos segundos…

)} {sinConexion && (

Sin conexión al servidor — el expediente se generó solo localmente

)} {errorValidacion && (

El servidor rechazó el alta: {errorValidacion}

)} {registro && (

Firma en remoto

Genera una liga pública (vigencia 72 h) para que el cliente firme y suba sus documentos desde su celular, sin usuario ni contraseña.

{ligaUrl && (

{ligaUrl} {ligaCopiada && ( Liga y mensaje de WhatsApp copiados )}

)} {errorLiga &&

{errorLiga}

}
)} actualizar('firma', f)} onAnterior={anterior} /> )}
) }