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

@@ -2,7 +2,13 @@ import { useEffect, useRef, useState } from 'react'
import Burbuja from '../components/Burbuja'
import DarkModeToggle from '../components/DarkModeToggle'
import toast from "react-hot-toast"
import { enviarMensaje, esErrorDeRed, obtenerHistorial } from '../services/api'
import {
enviarMensaje,
esErrorDeRed,
listarConversaciones,
obtenerHistorial,
type ConversacionResumen,
} from '../services/api'
import { cargarSesion, guardarSesion, limpiarSesion } from '../lib/sesion'
import type { Mensaje } from '../types'
@@ -23,6 +29,12 @@ export default function ChatPage() {
const [escribiendo, setEscribiendo] = useState(false)
const [sinConexion, setSinConexion] = useState(false)
const [error, setError] = useState<string | null>(null)
// Texto del último envío fallido (habilita "Reintentar" junto al input)
const [pendiente, setPendiente] = useState<string | null>(null)
// Panel de conversaciones previas
const [panelAbierto, setPanelAbierto] = useState(false)
const [conversaciones, setConversaciones] = useState<ConversacionResumen[] | null>(null)
const [cargandoConversaciones, setCargandoConversaciones] = useState(false)
const finalRef = useRef<HTMLDivElement>(null)
// Al cargar: si hay sesión en localStorage, recupera su historial del API
@@ -41,18 +53,15 @@ export default function ChatPage() {
finalRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [mensajes, escribiendo])
async function enviar() {
const texto = entrada.trim()
if (!texto || escribiendo) return
setEntrada('')
setError(null)
setMensajes((prev) => [...prev, { id: idLocal(), rol: 'user', contenido: texto, fuentes: [] }])
/** Envía el texto y añade la respuesta; en fallo deja el texto en `pendiente` */
async function transmitir(texto: string) {
setEscribiendo(true)
try {
const r = await enviarMensaje(sessionId, texto)
setSessionId(r.sessionId)
guardarSesion(r.sessionId)
setSinConexion(false)
setPendiente(null)
setMensajes((prev) => [
...prev,
{ id: idLocal(), rol: 'assistant', contenido: r.respuesta, fuentes: r.fuentes },
@@ -63,18 +72,63 @@ export default function ChatPage() {
} else {
toast.error('Error al enviar el mensaje'); setError(err instanceof Error ? err.message : 'Error al enviar el mensaje')
}
// el mensaje del usuario se queda visible; puede reintentar
setPendiente(texto) // el mensaje del usuario se queda visible; puede reintentar
} finally {
setEscribiendo(false)
}
}
async function enviar() {
const texto = entrada.trim()
if (!texto || escribiendo) return
setEntrada('')
setError(null)
setMensajes((prev) => [...prev, { id: idLocal(), rol: 'user', contenido: texto, fuentes: [] }])
await transmitir(texto)
}
/** Reenvía el último mensaje que falló (sin duplicar la burbuja del usuario) */
async function reintentar() {
if (!pendiente || escribiendo) return
setError(null)
setSinConexion(false)
await transmitir(pendiente)
}
function nuevaConversacion() {
limpiarSesion()
setSessionId(null)
setMensajes([])
setError(null)
setSinConexion(false)
setPendiente(null)
}
/** Abre el panel y carga (una vez) el listado de conversaciones del usuario */
function abrirPanel() {
setPanelAbierto(true)
if (conversaciones !== null || cargandoConversaciones) return
setCargandoConversaciones(true)
listarConversaciones()
.then(setConversaciones)
.catch(() => setConversaciones([]))
.finally(() => setCargandoConversaciones(false))
}
/** Carga el historial de una conversación previa y la vuelve la activa */
async function abrirConversacion(id: string) {
try {
const historial = await obtenerHistorial(id)
setSessionId(id)
guardarSesion(id)
setMensajes(historial)
setPendiente(null)
setError(null)
setSinConexion(false)
setPanelAbierto(false)
} catch {
toast.error('No se pudo cargar la conversación')
}
}
return (
@@ -96,6 +150,14 @@ export default function ChatPage() {
</div>
<div className="flex items-center gap-2">
<DarkModeToggle />
<button
type="button"
onClick={abrirPanel}
aria-expanded={panelAbierto}
className="rounded-lg border border-[#D1D5DB] bg-white px-4 dark:border-[#334155] dark:bg-[#0F172A] dark:text-[#E2E8F0] py-2 text-xs font-medium text-[#374151] dark:text-[#CBD5E1] shadow-sm transition-all duration-100 ease-out hover:bg-[#F9FAFB]"
>
Historial
</button>
<button
type="button"
onClick={nuevaConversacion}
@@ -163,6 +225,20 @@ export default function ChatPage() {
</p>
</div>
)}
{pendiente && !escribiendo && (
<div className="flex items-center justify-between gap-3 py-2">
<p className="text-xs font-medium text-red-600">
No se pudo enviar tu último mensaje.
</p>
<button
type="button"
onClick={() => void reintentar()}
className="rounded-lg bg-accent px-4 py-1.5 text-xs font-medium text-white shadow-sm transition-colors hover:bg-accent-hover"
>
Reintentar
</button>
</div>
)}
<form
className="flex items-stretch gap-3 py-4"
onSubmit={(e) => {
@@ -192,6 +268,64 @@ export default function ChatPage() {
</form>
</div>
</footer>
{/* Panel lateral: conversaciones previas del usuario */}
{panelAbierto && (
<div className="fixed inset-0 z-30 flex justify-end bg-black/40" onClick={() => setPanelAbierto(false)}>
<aside
className="h-full w-80 max-w-[85vw] overflow-y-auto border-l border-[#E5E7EB] bg-white p-4 dark:border-[#1E293B] dark:bg-[#1E293B]"
role="dialog"
aria-label="Conversaciones previas"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-[#111827] dark:text-[#E2E8F0]">Conversaciones previas</h2>
<button
type="button"
onClick={() => setPanelAbierto(false)}
aria-label="Cerrar historial"
className="rounded-lg px-2 py-1 text-sm text-[#64748B] dark:text-[#94A3B8] transition-colors hover:bg-[#F9FAFB] dark:hover:bg-[#334155]"
>
✕
</button>
</div>
{cargandoConversaciones ? (
<p className="mt-6 text-sm text-[#94A3B8]">Cargando…</p>
) : !conversaciones || conversaciones.length === 0 ? (
<p className="mt-6 text-sm text-[#94A3B8]">Sin conversaciones previas.</p>
) : (
<ul className="mt-4 space-y-2">
{conversaciones.map((c) => (
<li key={c.id}>
<button
type="button"
onClick={() => void abrirConversacion(c.id)}
className={`w-full rounded-lg border p-3 text-left transition-colors ${
c.id === sessionId
? 'border-accent bg-blue-50/60 dark:bg-blue-950/30'
: 'border-[#E5E7EB] hover:border-accent dark:border-[#334155]'
}`}
>
<p className="truncate text-xs text-[#111827] dark:text-[#E2E8F0]">
{c.ultimoMensaje?.contenido || 'Conversación sin mensajes'}
</p>
<p className="mt-1 text-[10px] text-[#94A3B8]">
{new Date(c.createdAt).toLocaleString('es-MX', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})}
</p>
</button>
</li>
))}
</ul>
)}
</aside>
</div>
)}
</div>
)
}

View File

@@ -73,3 +73,14 @@ export async function obtenerHistorial(sessionId: string): Promise<Mensaje[]> {
fuentes: m.fuentes ?? [],
}))
}
export interface ConversacionResumen {
id: string
createdAt: string
ultimoMensaje: { rol: string; contenido: string } | null
}
/** GET /ai/conversations — conversaciones previas del usuario (más reciente primero) */
export function listarConversaciones(): Promise<ConversacionResumen[]> {
return request<ConversacionResumen[]>('/ai/conversations')
}