970 lines
40 KiB
TypeScript
970 lines
40 KiB
TypeScript
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 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,
|
|
importarInformacionEnkontrol,
|
|
iniciarProgreso,
|
|
listarBorradores,
|
|
obtenerBorrador,
|
|
obtenerCotizacion,
|
|
obtenerPrefillApartado,
|
|
subirDocumentos,
|
|
type BorradorResumen,
|
|
type LeadResumen,
|
|
type ResultadoImportacionEnkontrol,
|
|
} from '../services/api'
|
|
import { getSesion } from '../services/auth'
|
|
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'
|
|
import FotoCliente from '../components/FotoCliente'
|
|
import ChecklistDocumentos from '../components/ChecklistDocumentos'
|
|
|
|
/** Lee ?cotizacion=<id> del query string o del hash (#cotizacion=<id>) */
|
|
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=<oportunidadId> + prefill (nombre/telefono/email/desarrollo/
|
|
* modelo) — botón "Afluencia" de Mis oportunidades en Mi día.
|
|
*/
|
|
function paramsAfluencia(): { oportunidadId: string; valores: Record<string, string> } | null {
|
|
const qs = new URLSearchParams(window.location.search)
|
|
const id = qs.get('afluencia')
|
|
if (!id) return null
|
|
const valores: Record<string, string> = {}
|
|
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=<itemId> — 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 <div className="rounded-xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">{children}</div>
|
|
}
|
|
|
|
/** Cuadro con las iniciales de la opción (icono de la tarjeta de entrada) */
|
|
function IconoIniciales({ iniciales }: { iniciales: string }) {
|
|
return (
|
|
<span
|
|
aria-hidden="true"
|
|
className="flex h-11 w-11 items-center justify-center rounded-lg bg-accent/10 text-sm font-semibold text-accent"
|
|
>
|
|
{iniciales}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
/** Flecha del CTA de las tarjetas de entrada */
|
|
function IconoFlecha() {
|
|
return (
|
|
<svg viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" className="h-4 w-4">
|
|
<path
|
|
fillRule="evenodd"
|
|
d="M10.293 3.293a1 1 0 011.414 0l6 6a1 1 0 010 1.414l-6 6a1 1 0 01-1.414-1.414L14.586 11H3a1 1 0 110-2h11.586l-4.293-4.293a1 1 0 010-1.414z"
|
|
clipRule="evenodd"
|
|
/>
|
|
</svg>
|
|
)
|
|
}
|
|
|
|
export default function AltaClientePage() {
|
|
const {
|
|
paso,
|
|
siguiente,
|
|
anterior,
|
|
irAPaso,
|
|
datos,
|
|
alta,
|
|
actualizar,
|
|
restaurar,
|
|
reiniciar,
|
|
agregarDocumento,
|
|
quitarDocumento,
|
|
} = useAltaCliente()
|
|
const [registro, setRegistro] = useState<string>('')
|
|
const [sinConexion, setSinConexion] = useState(false)
|
|
const [errorValidacion, setErrorValidacion] = useState<string>('')
|
|
/** Falta un documento obligatorio (p. ej. INE) al avanzar del paso Documentos */
|
|
const [errorDocumentos, setErrorDocumentos] = useState('')
|
|
/** Documentos que no se pudieron subir tras registrar el alta */
|
|
const [avisoDocumentos, setAvisoDocumentos] = useState('')
|
|
/** El alta se está registrando en el servidor */
|
|
const [registrandoAlta, setRegistrandoAlta] = useState(false)
|
|
|
|
// ---------- Borradores ----------
|
|
type Pantalla = 'cargando' | 'entrada' | 'picker' | 'afluencia' | 'wizard'
|
|
const [pantalla, setPantalla] = useState<Pantalla>('cargando')
|
|
const [borradores, setBorradores] = useState<BorradorResumen[]>([])
|
|
const [borradorId, setBorradorId] = useState<string | null>(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('')
|
|
|
|
// ---------- Importación del documento de Enkontrol (solo administración) ----------
|
|
const rolSesion = getSesion()?.usuario.rol ?? ''
|
|
const puedeImportarEnkontrol = rolSesion === 'admin' || rolSesion === 'manager'
|
|
const [importandoEnkontrol, setImportandoEnkontrol] = useState(false)
|
|
const [resultadoImportacion, setResultadoImportacion] = useState<ResultadoImportacionEnkontrol | null>(null)
|
|
const [errorImportacion, setErrorImportacion] = useState('')
|
|
const inputImportacionRef = useRef<HTMLInputElement>(null)
|
|
|
|
const importarEnkontrol = async (archivo: File | undefined) => {
|
|
if (!archivo) return
|
|
setImportandoEnkontrol(true)
|
|
setErrorImportacion('')
|
|
setResultadoImportacion(null)
|
|
try {
|
|
setResultadoImportacion(await importarInformacionEnkontrol(archivo))
|
|
} catch (err) {
|
|
setErrorImportacion(err instanceof Error ? err.message : 'No se pudo importar el documento.')
|
|
} finally {
|
|
setImportandoEnkontrol(false)
|
|
if (inputImportacionRef.current) inputImportacionRef.current.value = ''
|
|
}
|
|
}
|
|
|
|
// 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<string, string>
|
|
} | null>(null)
|
|
|
|
// Al entrar: ?apartado=<id> prellena el alta con todo lo recolectado del
|
|
// apartado y salta al wizard ligado a ese item; ?afluencia=<id> abre el
|
|
// formulario de afluencia prellenado; ?cotizacion=<id> 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 ya capturado (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 (pasoDestino: number = paso) => {
|
|
try {
|
|
const b = await guardarBorrador({ ...(borradorId ? { id: borradorId } : {}), paso: pasoDestino, 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')
|
|
}
|
|
}
|
|
|
|
// ---------- Guard de cambios sin guardar (saltos con el Stepper) ----------
|
|
// El formulario del paso guarda sus capturas solo al avanzar con "Siguiente";
|
|
// si el usuario salta a otro paso con el Stepper, se le ofrece guardar antes.
|
|
const [sucio, setSucio] = useState(false)
|
|
// Paso elegido en el Stepper mientras el confirm de cambios sin guardar está abierto
|
|
const [destinoPendiente, setDestinoPendiente] = useState<number | null>(null)
|
|
|
|
// Al cambiar de paso el formulario se desmonta y remonta: se parte de cero
|
|
useEffect(() => {
|
|
setSucio(false)
|
|
}, [paso])
|
|
|
|
/** Cualquier edición dentro del paso actual marca cambios sin guardar */
|
|
const marcarSucio = () => setSucio(true)
|
|
|
|
/** Cambio de paso vía Stepper: confirma antes de saltar si hay cambios sin guardar */
|
|
const irAPasoConGuard = (destino: number) => {
|
|
if (destino === paso) return
|
|
if (sucio) {
|
|
setDestinoPendiente(destino)
|
|
return
|
|
}
|
|
irAPaso(destino)
|
|
}
|
|
|
|
/** "Guardar": persiste el borrador apuntando al paso destino y continúa hacia él */
|
|
const guardarYContinuar = async () => {
|
|
const destino = destinoPendiente
|
|
if (destino === null) return
|
|
setDestinoPendiente(null)
|
|
await guardar(destino)
|
|
irAPaso(destino)
|
|
}
|
|
|
|
/** "Continuar sin guardar": descarta lo capturado en el paso y salta al destino */
|
|
const continuarSinGuardar = () => {
|
|
const destino = destinoPendiente
|
|
if (destino === null) return
|
|
setDestinoPendiente(null)
|
|
setSucio(false)
|
|
irAPaso(destino)
|
|
}
|
|
|
|
// ---------- Guardado progresivo (borrador local + alta en progreso) ----------
|
|
// 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 en este dispositivo; no se pudo subir al servidor')
|
|
} finally {
|
|
syncEnCurso.current = false
|
|
}
|
|
}
|
|
|
|
/** Al avanzar de paso: guarda el borrador en silencio y sube el avance al servidor */
|
|
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 () => {
|
|
setRegistrandoAlta(true)
|
|
const res = datos.progresoId
|
|
? await completarProgreso(datos.progresoId, alta)
|
|
: await crearAltaCliente(alta)
|
|
if (res.ok) {
|
|
toast.success('Alta de cliente completada')
|
|
const { fallidos } = await subirDocumentos(res.id, alta)
|
|
setRegistro(res.id)
|
|
setSinConexion(false)
|
|
setErrorValidacion('')
|
|
// El alta quedó registrada, pero algunos archivos no subieron: se avisa
|
|
// con el detalle para que el asesor los vuelva a adjuntar.
|
|
if (fallidos.length > 0) {
|
|
setAvisoDocumentos(
|
|
`El alta se registró, pero no se pudieron subir ${fallidos.length} documento(s): ` +
|
|
`${fallidos.map((f) => f.nombre).join(', ')}. Vuelve a adjuntarlos desde el expediente del cliente.`,
|
|
)
|
|
} else {
|
|
setAvisoDocumentos('')
|
|
}
|
|
// 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('')
|
|
}
|
|
setRegistrandoAlta(false)
|
|
}
|
|
|
|
// Pantalla de éxito tras registrar el alta (en lugar del redirect automático a Mi día)
|
|
const registrarOtro = () => {
|
|
setRegistro('')
|
|
setSinConexion(false)
|
|
setErrorValidacion('')
|
|
setLigaUrl('')
|
|
setLigaCopiada(false)
|
|
setErrorLiga('')
|
|
empezarNuevo()
|
|
}
|
|
|
|
const irAMiDia = () => {
|
|
window.location.href = '/hoy/'
|
|
}
|
|
|
|
// 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 (
|
|
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
|
|
<main className="px-4 py-16 sm:px-6 lg:px-8">
|
|
<SkeletonCard />
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (pantalla === 'entrada') {
|
|
return (
|
|
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
|
|
<AppHeader titulo="Alta de Clientes" />
|
|
<main className="px-4 py-8 sm:px-6 sm:py-12 lg:px-8">
|
|
<div className="mx-auto max-w-4xl">
|
|
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
|
|
Elige cómo registrar al interesado: captura ligera de afluencia o formato completo de alta.
|
|
</p>
|
|
<div className="mt-6 grid gap-6 sm:grid-cols-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setPantalla('afluencia')}
|
|
className="rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] text-left shadow-sm transition-all duration-200 ease-in-out hover:border-accent hover:shadow-md"
|
|
>
|
|
<IconoIniciales iniciales="AA" />
|
|
<span className="mt-4 block text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">Alta Afluencia</span>
|
|
<span className="mt-2 block text-sm text-[#64748B] dark:text-[#94A3B8]">
|
|
Captura rápida del interesado; entra al embudo de ventas.
|
|
</span>
|
|
<span className="mt-5 inline-flex items-center gap-1.5 text-sm font-medium text-accent">
|
|
Comenzar
|
|
<IconoFlecha />
|
|
</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setPantalla('picker')}
|
|
className="rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] text-left shadow-sm transition-all duration-200 ease-in-out hover:border-accent hover:shadow-md"
|
|
>
|
|
<IconoIniciales iniciales="AC" />
|
|
<span className="mt-4 block text-lg font-semibold text-[#111827] dark:text-[#E2E8F0]">Alta de Cliente</span>
|
|
<span className="mt-2 block text-sm text-[#64748B] dark:text-[#94A3B8]">
|
|
Formato completo para quien aparta.
|
|
</span>
|
|
<span className="mt-5 inline-flex items-center gap-1.5 text-sm font-medium text-accent">
|
|
Comenzar
|
|
<IconoFlecha />
|
|
</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (pantalla === 'afluencia') {
|
|
return (
|
|
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
|
|
<AppHeader titulo="Alta Afluencia" />
|
|
<main className="px-4 py-8 sm:px-6 sm:py-10 lg:px-8">
|
|
<div className="mx-auto max-w-3xl">
|
|
<p className="text-sm text-[#64748B] dark:text-[#94A3B8]">
|
|
Captura rápida del interesado; entra al embudo de ventas.
|
|
</p>
|
|
<div className="mt-6 rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
|
<AltaAfluenciaForm
|
|
valoresIniciales={afluenciaPrevia?.valores}
|
|
oportunidadId={afluenciaPrevia?.oportunidadId}
|
|
onVolver={() => {
|
|
setAfluenciaPrevia(null)
|
|
setPantalla('entrada')
|
|
}}
|
|
onPasarAAltaCliente={empezarDesdeLead}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (pantalla === 'picker') {
|
|
return (
|
|
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
|
|
<AppHeader titulo="Alta de Clientes" />
|
|
<main className="px-4 py-8 sm:px-6 sm:py-10 lg:px-8">
|
|
<div className="max-w-3xl">
|
|
{borradores.length > 0 && (
|
|
<>
|
|
<h2 className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
|
Tienes {borradores.length} borrador{borradores.length === 1 ? '' : 'es'} guardado
|
|
{borradores.length === 1 ? '' : 's'}
|
|
</h2>
|
|
<ul className="mt-6 divide-y divide-[#E5E7EB] rounded-xl border border-[#E5E7EB] bg-white shadow-sm dark:border-[#1E293B] dark:bg-[#1E293B]">
|
|
{borradores.map((b) => (
|
|
<li key={b.id} className="flex flex-wrap items-center justify-between gap-4 px-4 py-3">
|
|
<div className="min-w-0">
|
|
<p className="truncate text-sm text-[#111827] dark:text-[#E2E8F0]">{b.nombre}</p>
|
|
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
|
Paso {b.paso + 1} de {PASOS.length} · {formatoFechaHora(b.updatedAt)}
|
|
</p>
|
|
</div>
|
|
<div className="flex shrink-0 gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => void continuarBorrador(b.id)}
|
|
className="rounded-lg border border-accent bg-white px-3 py-1.5 text-xs font-medium text-accent transition-all duration-100 ease-out hover:bg-accent hover:text-white"
|
|
>
|
|
Continuar
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void borrarBorrador(b.id)}
|
|
className="rounded-lg border border-red-200 bg-white px-3 py-1.5 text-xs font-medium text-red-600 transition-all duration-100 ease-out hover:bg-red-50"
|
|
>
|
|
Eliminar
|
|
</button>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</>
|
|
)}
|
|
{errorCarga && <p className="mt-4 text-xs text-red-700">{errorCarga}</p>}
|
|
<button
|
|
type="button"
|
|
onClick={empezarNuevo}
|
|
className="mt-8 rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover"
|
|
>
|
|
Empezar nuevo
|
|
</button>
|
|
|
|
<section aria-labelledby="titulo-lead" className="mt-12 border-t border-[#E5E7EB] dark:border-[#1E293B] pt-8">
|
|
<h3 id="titulo-lead" className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
|
Desde un lead ya capturado
|
|
</h3>
|
|
<p className="mt-2 text-sm text-[#64748B] dark:text-[#94A3B8]">
|
|
Empieza un alta nueva con el nombre, email y celular del lead ya capturados.
|
|
</p>
|
|
<div className="mt-4">
|
|
<BusquedaLead onSeleccion={empezarDesdeLead} />
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// ---------- Pantalla de éxito: el alta quedó registrada en el servidor ----------
|
|
if (registro) {
|
|
return (
|
|
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
|
|
<AppHeader titulo="Alta de Clientes" />
|
|
<main className="px-4 py-8 sm:px-6 sm:py-12 lg:px-8">
|
|
<div className="mx-auto max-w-2xl">
|
|
<div className="rounded-xl border border-green-200 bg-green-50 p-6 dark:border-green-900 dark:bg-green-950/40">
|
|
<div className="flex items-center gap-3">
|
|
<span
|
|
aria-hidden="true"
|
|
className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-green-100 text-lg text-green-700 dark:bg-green-900 dark:text-green-400"
|
|
>
|
|
✓
|
|
</span>
|
|
<div className="min-w-0">
|
|
<h1 className="text-lg font-semibold text-green-800 dark:text-green-300">
|
|
Alta registrada correctamente
|
|
</h1>
|
|
<p className="mt-0.5 truncate text-xs text-green-700 dark:text-green-400">
|
|
{alta.datosGenerales.nombreCompleto || 'Cliente'} · Folio {registro}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{avisoDocumentos !== '' && (
|
|
<p
|
|
role="alert"
|
|
className="mt-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-800"
|
|
>
|
|
{avisoDocumentos}
|
|
</p>
|
|
)}
|
|
|
|
<div className="mt-6 flex flex-col gap-3 sm:flex-row sm:flex-wrap">
|
|
<button
|
|
type="button"
|
|
onClick={() => void generarLiga()}
|
|
className="rounded-lg bg-accent px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover"
|
|
>
|
|
Generar liga de firma
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={irAMiDia}
|
|
className="rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
|
>
|
|
Ir a Mi día
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={registrarOtro}
|
|
className="rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
|
>
|
|
Registrar otro cliente
|
|
</button>
|
|
</div>
|
|
|
|
{puedeImportarEnkontrol && (
|
|
<div className="mt-6 rounded-lg border border-[#E5E7EB] bg-white p-4 dark:border-[#334155] dark:bg-[#0F172A]">
|
|
<p className="text-sm font-medium text-[#111827] dark:text-[#E2E8F0]">
|
|
Importar información de Enkontrol
|
|
</p>
|
|
<p className="mt-1 text-xs text-[#64748B] dark:text-[#94A3B8]">
|
|
Sube el documento de Enkontrol (Excel o CSV). Cada fila se empareja con la ficha
|
|
del cliente usando el ID de Enkontrol como campo determinante y su información se
|
|
guarda en la ficha.
|
|
</p>
|
|
<div className="mt-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => inputImportacionRef.current?.click()}
|
|
disabled={importandoEnkontrol}
|
|
className="rounded-lg border border-accent bg-white px-4 py-2 text-xs font-medium text-accent shadow-sm transition-all duration-100 ease-out hover:bg-accent hover:text-white disabled:opacity-50 dark:bg-[#0F172A] dark:border-accent dark:text-accent"
|
|
>
|
|
{importandoEnkontrol ? 'Importando…' : 'Seleccionar documento…'}
|
|
</button>
|
|
<input
|
|
ref={inputImportacionRef}
|
|
type="file"
|
|
accept=".xlsx,.xls,.csv"
|
|
className="sr-only"
|
|
onChange={(e) => void importarEnkontrol(e.target.files?.[0])}
|
|
/>
|
|
</div>
|
|
{resultadoImportacion && (
|
|
<p role="status" className="mt-3 rounded-lg border border-green-200 bg-green-50 px-3 py-2 text-xs text-green-700 dark:border-green-800 dark:bg-green-900/30 dark:text-green-300">
|
|
Importación lista: {resultadoImportacion.actualizados} de {resultadoImportacion.totalFilas}{' '}
|
|
clientes actualizados.
|
|
{resultadoImportacion.noEncontrados.length > 0 && (
|
|
<> Sin ficha local: {resultadoImportacion.noEncontrados.slice(0, 10).join(', ')}
|
|
{resultadoImportacion.noEncontrados.length > 10 ? '…' : ''}</>
|
|
)}
|
|
</p>
|
|
)}
|
|
{errorImportacion && (
|
|
<p role="alert" className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700">
|
|
{errorImportacion}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<p className="mt-6 text-xs text-green-800/80 dark:text-green-400/80">
|
|
Con la liga de firma, el cliente firma su expediente y sube documentos desde su
|
|
celular (vigencia 72 h, sin usuario ni contraseña).
|
|
</p>
|
|
{ligaUrl && (
|
|
<p className="mt-3 break-all text-xs text-[#111827] dark:text-[#E2E8F0]">
|
|
{ligaUrl}
|
|
{ligaCopiada && (
|
|
<span role="status" className="ml-2 font-medium text-accent">
|
|
Liga y mensaje de WhatsApp copiados
|
|
</span>
|
|
)}
|
|
</p>
|
|
)}
|
|
{errorLiga && <p className="mt-2 text-xs text-red-700">{errorLiga}</p>}
|
|
</div>
|
|
</div>
|
|
</main>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-[#F8FAFC] dark:bg-[#0F172A]">
|
|
<AppHeader
|
|
titulo="Alta de Clientes"
|
|
acciones={
|
|
<>
|
|
{avisoBorrador && (
|
|
<span role="status" className="text-xs text-[#64748B] dark:text-[#94A3B8]">
|
|
{avisoBorrador}
|
|
</span>
|
|
)}
|
|
{avisoProgreso && (
|
|
<span role="status" className="text-xs text-amber-700">
|
|
{avisoProgreso}
|
|
</span>
|
|
)}
|
|
<button
|
|
type="button"
|
|
onClick={() => void guardar()}
|
|
className="rounded-lg border border-[#D1D5DB] bg-white px-5 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2.5 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
|
>
|
|
Guardar borrador
|
|
</button>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
<main className="px-4 py-6 sm:px-6 sm:py-8 lg:px-8">
|
|
<div className="md:grid md:grid-cols-[260px_minmax(0,1fr)] md:gap-8 lg:gap-8">
|
|
<div className="mb-6 md:sticky md:top-20 md:mb-0 md:self-start">
|
|
<Stepper pasos={[...PASOS]} pasoActual={paso} onIrAPaso={irAPasoConGuard} />
|
|
</div>
|
|
|
|
<div className="w-full max-w-3xl" onInput={marcarSucio} onChange={marcarSucio}>
|
|
{errorCarga && (
|
|
<p role="alert" className="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700">
|
|
{errorCarga}
|
|
</p>
|
|
)}
|
|
{datos.apartadoMondayId && (
|
|
<p role="note" className="mb-6 rounded-lg border border-blue-200 bg-blue-50 px-4 py-3 text-xs font-medium text-blue-800">
|
|
Alta ligada al apartado del cliente — al completarla se actualiza ese apartado (no se duplica).
|
|
</p>
|
|
)}
|
|
|
|
{paso === 0 && (
|
|
<>
|
|
<TarjetaPaso>
|
|
<DatosGeneralesForm
|
|
valores={datos.datosGenerales}
|
|
onSiguiente={(v) => {
|
|
actualizar('datosGenerales', v)
|
|
siguiente()
|
|
guardarAvance({ ...datos, datosGenerales: v }, paso + 1)
|
|
}}
|
|
/>
|
|
</TarjetaPaso>
|
|
<div className="mt-4">
|
|
<div className="rounded-xl border border-[#E5E7EB] bg-white p-5 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
|
|
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
|
|
Foto del cliente
|
|
</p>
|
|
<div className="mt-3">
|
|
<FotoCliente
|
|
foto={datos.foto}
|
|
onFoto={(foto) => {
|
|
actualizar('foto', foto || null)
|
|
marcarSucio()
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
)}
|
|
{paso === 1 && (
|
|
<TarjetaPaso>
|
|
<EmpleoForm
|
|
valores={datos.empleo}
|
|
onAnterior={anterior}
|
|
onSiguiente={(v) => {
|
|
actualizar('empleo', v)
|
|
siguiente()
|
|
guardarAvance({ ...datos, empleo: v }, paso + 1)
|
|
}}
|
|
/>
|
|
</TarjetaPaso>
|
|
)}
|
|
{paso === 2 && (
|
|
<TarjetaPaso>
|
|
<EconomicosForm
|
|
valores={datos.economicos}
|
|
onAnterior={anterior}
|
|
onSiguiente={(v) => {
|
|
actualizar('economicos', v)
|
|
siguiente()
|
|
guardarAvance({ ...datos, economicos: v }, paso + 1)
|
|
}}
|
|
/>
|
|
</TarjetaPaso>
|
|
)}
|
|
{paso === 3 && (
|
|
<TarjetaPaso>
|
|
<CoacreditadoForm
|
|
valores={datos.coacreditado}
|
|
onAnterior={anterior}
|
|
onSiguiente={(v) => {
|
|
actualizar('coacreditado', v)
|
|
siguiente()
|
|
guardarAvance({ ...datos, coacreditado: v }, paso + 1)
|
|
}}
|
|
/>
|
|
</TarjetaPaso>
|
|
)}
|
|
{paso === 4 && (
|
|
<TarjetaPaso>
|
|
<ReferenciasForm
|
|
valores={datos.referencias}
|
|
onAnterior={anterior}
|
|
onSiguiente={(v) => {
|
|
actualizar('referencias', v)
|
|
siguiente()
|
|
guardarAvance({ ...datos, referencias: v }, paso + 1)
|
|
}}
|
|
/>
|
|
</TarjetaPaso>
|
|
)}
|
|
{paso === 5 && (
|
|
<TarjetaPaso>
|
|
<ViviendaCreditoForm
|
|
valores={datos.viviendaCredito}
|
|
onAnterior={anterior}
|
|
onSiguiente={(v) => {
|
|
actualizar('viviendaCredito', v)
|
|
siguiente()
|
|
guardarAvance({ ...datos, viviendaCredito: v }, paso + 1)
|
|
}}
|
|
/>
|
|
</TarjetaPaso>
|
|
)}
|
|
{paso === 6 && (
|
|
<>
|
|
{errorDocumentos !== '' && (
|
|
<p
|
|
role="alert"
|
|
className="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700"
|
|
>
|
|
{errorDocumentos}
|
|
</p>
|
|
)}
|
|
{cargoDeBorrador && (
|
|
<p
|
|
role="note"
|
|
className="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700"
|
|
>
|
|
Retomaste un borrador: los archivos de los documentos no se guardan en el borrador,
|
|
vuelve a adjuntarlos.
|
|
</p>
|
|
)}
|
|
<TarjetaPaso>
|
|
<DocumentosForm
|
|
documentos={alta.documentos}
|
|
firmaDataUrl={alta.firma.firmaDataUrl}
|
|
onAgregar={agregarDocumento}
|
|
onQuitar={quitarDocumento}
|
|
onAplicarDatosGenerales={(campos) =>
|
|
actualizar('datosGenerales', { ...datos.datosGenerales, ...campos })
|
|
}
|
|
onAnterior={anterior}
|
|
onSiguiente={() => {
|
|
// La INE es obligatoria en el expediente: se avisa al pasar
|
|
// de paso (el asesor puede adjuntarla después o por la liga
|
|
// de firma), y el paso de firmas muestra lo que falte.
|
|
setErrorDocumentos(
|
|
alta.documentos.some((d) => d.slot === 'INE')
|
|
? ''
|
|
: 'Recuerda adjuntar la INE del cliente: es un documento obligatorio del expediente.',
|
|
)
|
|
siguiente()
|
|
guardarAvance(datos, paso + 1)
|
|
}}
|
|
/>
|
|
</TarjetaPaso>
|
|
<div className="mt-4">
|
|
<ChecklistDocumentos
|
|
recibidos={alta.documentos.map((d) => d.slot)}
|
|
onToggle={() => { /* toggle se maneja internamente */ }}
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
{paso === 7 && (
|
|
<>
|
|
{sinConexion && (
|
|
<p
|
|
role="alert"
|
|
className="mb-6 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-xs text-amber-700"
|
|
>
|
|
Sin conexión al servidor — el expediente se generó solo localmente
|
|
</p>
|
|
)}
|
|
{errorValidacion && (
|
|
<p
|
|
role="alert"
|
|
className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-xs text-red-700"
|
|
>
|
|
El servidor rechazó el alta: {errorValidacion}
|
|
</p>
|
|
)}
|
|
<TarjetaPaso>
|
|
<FirmasForm
|
|
alta={alta}
|
|
onCambiarFirma={(f) => actualizar('firma', f)}
|
|
onAnterior={anterior}
|
|
onRegistrar={() => void registrarAlta()}
|
|
registrando={registrandoAlta}
|
|
/>
|
|
</TarjetaPaso>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</main>
|
|
|
|
{/* Confirmación de cambios sin guardar al saltar de paso con el Stepper */}
|
|
{destinoPendiente !== null && (
|
|
<div
|
|
className="fixed inset-0 z-50 flex items-center justify-center bg-neutral-900/50 p-4"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="guard-cambios-titulo"
|
|
>
|
|
<div className="w-full max-w-md rounded-2xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-xl">
|
|
<h2
|
|
id="guard-cambios-titulo"
|
|
className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]"
|
|
>
|
|
Cambios sin guardar
|
|
</h2>
|
|
<p className="mt-2 text-sm text-[#4B5563] dark:text-[#CBD5E1]">
|
|
¿Deseas guardar antes de continuar?
|
|
</p>
|
|
<div className="mt-6 flex justify-end gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={continuarSinGuardar}
|
|
className="rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2 text-sm font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
|
|
>
|
|
Continuar sin guardar
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => void guardarYContinuar()}
|
|
className="rounded-lg bg-accent px-4 py-2 text-sm font-medium text-white shadow-sm transition-all duration-100 ease-out hover:bg-accent-hover"
|
|
>
|
|
Guardar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|