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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user