feat(mejoras): 25 mejoras UX en todos los módulos + purga Monday

Backend: mi-dia siempre local, vocabulario canónico, AplicacionPago con usuario
Mi día: modal español real, cita desde modal, score falso eliminado
Cotizador: barra sticky, persistencia local con folio
Alta Clientes: redirect eliminado, foto conectada, asesor desde sesión, refs dinámicas
Cobranza: contrato Mora arreglado, link a Plan, abono próximo pago, WhatsApp
Landing: contactos locales, ModalContacto eliminado, ModoTrabajo eliminado
Dashboards: MiTablero unificado, funnel con descartados, drill-down, rango fechas
Chat FRASA: corpus 5 desarrollos, 6 herramientas, historial conversaciones

69 archivos, 7 apps construidas
This commit is contained in:
2026-09-11 06:52:37 +00:00
parent 075d47c304
commit 64b01246d6
69 changed files with 1600 additions and 1133 deletions

View File

@@ -226,9 +226,9 @@ export default function AltaClientePage() {
setPantalla('wizard')
}
const guardar = async () => {
const guardar = async (pasoDestino: number = paso) => {
try {
const b = await guardarBorrador({ ...(borradorId ? { id: borradorId } : {}), paso, datos })
const b = await guardarBorrador({ ...(borradorId ? { id: borradorId } : {}), paso: pasoDestino, datos })
toast.success("Borrador guardado")
setBorradorId(b.id)
setAvisoBorrador(
@@ -239,6 +239,49 @@ export default function AltaClientePage() {
}
}
// ---------- 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 + 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).
@@ -309,14 +352,20 @@ export default function AltaClientePage() {
}
}
// 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])
// 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 () => {
@@ -485,6 +534,77 @@ export default function AltaClientePage() {
)
}
// ---------- 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>
<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>
<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
@@ -515,10 +635,10 @@ export default function AltaClientePage() {
<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={irAPaso} />
<Stepper pasos={[...PASOS]} pasoActual={paso} onIrAPaso={irAPasoConGuard} />
</div>
<div className="w-full max-w-3xl">
<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}
@@ -549,9 +669,10 @@ export default function AltaClientePage() {
</p>
<div className="mt-3">
<FotoCliente
foto={null}
onFoto={() => {
// Foto capturada (el componente gestiona el preview internamente)
foto={datos.foto}
onFoto={(foto) => {
actualizar('foto', foto || null)
marcarSucio()
}}
/>
</div>
@@ -662,16 +783,6 @@ export default function AltaClientePage() {
)}
{paso === 7 && (
<>
{registro && (
<p className="mb-6 rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm font-medium text-green-700">
Alta registrada en el servidor: {registro}
</p>
)}
{registro && (
<p className="mb-6 text-xs text-[#64748B] dark:text-[#94A3B8]">
Redirigiendo a Mi día en unos segundos…
</p>
)}
{sinConexion && (
<p
role="alert"
@@ -688,35 +799,6 @@ export default function AltaClientePage() {
El servidor rechazó el alta: {errorValidacion}
</p>
)}
{registro && (
<div className="mb-6 rounded-xl border border-[#E5E7EB] bg-white p-6 dark:border-[#1E293B] dark:bg-[#1E293B] shadow-sm">
<p className="text-xs font-semibold uppercase tracking-wider text-[#64748B] dark:text-[#94A3B8]">
Firma en remoto
</p>
<p className="mt-2 text-sm text-[#64748B] dark:text-[#94A3B8]">
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.
</p>
<button
type="button"
onClick={() => void generarLiga()}
className="mt-4 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]"
>
Generar liga para firmar en remoto
</button>
{ligaUrl && (
<p className="mt-4 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>
)}
<TarjetaPaso>
<FirmasForm
alta={alta}
@@ -729,6 +811,44 @@ export default function AltaClientePage() {
</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>
)
}