Oleada UX 7: Chat (presencia, busqueda en historial) y WACRM (metricas para gerencia, notas internas y etiquetas que sobreviven al sync) + fixes de concurrencia

This commit is contained in:
2026-09-17 23:46:40 +00:00
parent 31c92ac419
commit c38783eb31
13 changed files with 1589 additions and 36 deletions

View File

@@ -1,7 +1,7 @@
import type { FC, FormEvent, KeyboardEvent } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Send, Search, ArrowLeft } from 'lucide-react';
import { Send, Search, ArrowLeft, X, History } from 'lucide-react';
import Layout from '../components/Layout';
import { Card, Button, EmptyState, Skeleton, toast } from '../components/ui';
import { ROLE_LABELS } from '../lib/auth';import { odooApi, type ChatUser, type ChatMessage } from '../services/odoo';
@@ -9,6 +9,22 @@ import { setActiveChatUser } from '../lib/chat-notifications';
const POLL_MS = 5000;
/** Resalta las coincidencias de `q` dentro del texto. */
const resaltar = (texto: string, q: string) => {
if (!q || q.length < 2) return texto;
const idx = texto.toLowerCase().indexOf(q.toLowerCase());
if (idx < 0) return texto;
return (
<>
{texto.slice(0, idx)}
<mark className="bg-amber-200 text-theme-heading rounded px-0.5">{texto.slice(idx, idx + q.length)}</mark>
{texto.slice(idx + q.length)}
</>
);
};
const PRESENCIA_CLS = 'inline-block h-2.5 w-2.5 rounded-full shrink-0';
// Las fechas llegan en UTC ("YYYY-MM-DD HH:MM:SS"); se muestran en hora local
const fmtTime = (d: string | null): string => {
if (!d) return '';
@@ -42,6 +58,14 @@ const Chat: FC = () => {
const bottomRef = useRef<HTMLDivElement>(null);
const [searchParams] = useSearchParams();
// Búsqueda dentro del historial del hilo abierto
const [histOpen, setHistOpen] = useState(false);
const [histQuery, setHistQuery] = useState('');
const [histResults, setHistResults] = useState<ChatMessage[]>([]);
const [histLoading, setHistLoading] = useState(false);
const [highlightId, setHighlightId] = useState<number | null>(null);
const highlightRef = useRef<HTMLDivElement>(null);
const activeUser = users.find((u) => u.id === activeId) || null;
const fetchUsers = useCallback(async () => {
@@ -55,9 +79,13 @@ const Chat: FC = () => {
}
}, []);
const fetchThread = useCallback(async (otherId: number, incremental: boolean) => {
const fetchThread = useCallback(async (otherId: number, incremental: boolean, aroundId?: number) => {
try {
const res = await odooApi.getChatMessages(otherId, incremental ? lastIdRef.current : undefined);
const res = await odooApi.getChatMessages(
otherId,
incremental ? lastIdRef.current : undefined,
aroundId,
);
if (res.status !== 'success') return;
if (incremental) {
if (res.messages.length === 0) return;
@@ -69,7 +97,13 @@ const Chat: FC = () => {
setMessages(res.messages);
}
lastIdRef.current = Math.max(...res.messages.map((m) => m.id), lastIdRef.current);
setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' }), 50);
if (aroundId) {
// Salto a un mensaje antiguo: centrarlo en vez de ir al final
setHighlightId(aroundId);
setTimeout(() => highlightRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' }), 80);
} else {
setTimeout(() => bottomRef.current?.scrollIntoView({ behavior: 'smooth' }), 50);
}
} catch {
// silencioso en polling
}
@@ -79,6 +113,10 @@ const Chat: FC = () => {
setActiveId(id);
setMessages([]);
lastIdRef.current = 0;
setHistOpen(false);
setHistQuery('');
setHistResults([]);
setHighlightId(null);
setLoadingThread(true);
await fetchThread(id, false);
setLoadingThread(false);
@@ -86,6 +124,36 @@ const Chat: FC = () => {
fetchUsers();
}, [fetchThread, fetchUsers]);
// Buscar en el historial (servidor)
const buscarHistorial = useCallback(async () => {
if (!activeId || histQuery.trim().length < 2) return;
try {
setHistLoading(true);
const res = await odooApi.searchChatMessages(activeId, histQuery.trim());
if (res.status === 'success') setHistResults(res.results);
} catch {
toast.error('No se pudo buscar en el historial');
} finally {
setHistLoading(false);
}
}, [activeId, histQuery]);
// Saltar a un resultado: si ya está cargado se centra; si es antiguo se
// pide la ventana de historial que termina en ese mensaje.
const irAlMensaje = useCallback(async (m: ChatMessage) => {
if (!activeId) return;
const cargado = messages.some((x) => x.id === m.id);
setHistOpen(false);
if (cargado) {
setHighlightId(m.id);
setTimeout(() => highlightRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' }), 80);
} else {
setLoadingThread(true);
await fetchThread(activeId, false, m.id);
setLoadingThread(false);
}
}, [activeId, messages, fetchThread]);
// Carga inicial + polling de lista y hilo abierto
useEffect(() => {
fetchUsers();
@@ -186,8 +254,19 @@ const Chat: FC = () => {
onClick={() => openConversation(u.id)}
className={`w-full flex items-center gap-3 px-3 py-3 text-left transition-colors hover:bg-theme-accent-bg ${activeId === u.id ? 'bg-theme-accent-bg' : ''}`}
>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-brand-lilac text-xs font-bold text-theme-heading">
{initials(u.name)}
<span className="relative shrink-0">
<span className="flex h-10 w-10 items-center justify-center rounded-full bg-brand-lilac text-xs font-bold text-theme-heading">
{initials(u.name)}
</span>
<span
className={`absolute -bottom-0 -right-0 ${PRESENCIA_CLS} border-2 border-theme-surface ${
u.online ? 'bg-emerald-500' : 'bg-theme-muted/40'
}`}
data-presencia={u.online ? 'online' : 'offline'}
title={u.online
? 'En línea'
: u.last_seen ? `Visto ${fmtDay(u.last_seen)} ${fmtTime(u.last_seen)}` : 'Sin actividad'}
/>
</span>
<span className="flex-1 min-w-0">
<span className="flex items-center justify-between gap-2">
@@ -237,15 +316,93 @@ const Chat: FC = () => {
>
<ArrowLeft size={18} />
</button>
<span className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-brand-lilac text-xs font-bold text-theme-heading">
{initials(activeUser.name)}
<span className="relative shrink-0">
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-brand-lilac text-xs font-bold text-theme-heading">
{initials(activeUser.name)}
</span>
<span
className={`absolute -bottom-0 -right-0 h-2.5 w-2.5 rounded-full border-2 border-theme-surface ${
activeUser.online ? 'bg-emerald-500' : 'bg-theme-muted/40'
}`}
data-presencia={activeUser.online ? 'online' : 'offline'}
/>
</span>
<div className="min-w-0">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-theme-heading">{activeUser.name}</p>
<p className="text-xs text-theme-muted">{ROLE_LABELS[activeUser.role] || activeUser.role}</p>
<p className="text-xs text-theme-muted">
{activeUser.online
? 'En línea'
: activeUser.last_seen
? `Visto ${fmtDay(activeUser.last_seen)} ${fmtTime(activeUser.last_seen)}`
: ROLE_LABELS[activeUser.role] || activeUser.role}
</p>
</div>
<button
type="button"
onClick={() => { setHistOpen((v) => !v); setHistResults([]); }}
className={`shrink-0 inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs font-medium transition ${
histOpen ? 'border-theme-accent bg-theme-accent-bg text-theme-heading' : 'border-theme-border text-theme-muted hover:border-theme-border-strong'
}`}
title="Buscar en el historial de esta conversación"
>
<History size={13} />
Buscar
</button>
</div>
{histOpen && (
<div className="px-4 py-3 border-b border-theme-border bg-theme-surface">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-theme-muted" />
<input
type="text"
autoFocus
placeholder="Buscar en el historial..."
value={histQuery}
onChange={(e) => setHistQuery(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') buscarHistorial(); }}
className="w-full rounded-xl border border-theme-border-strong bg-theme-surface pl-9 pr-3 py-2 text-sm text-theme-heading focus:outline-none focus:ring-2 focus:ring-theme-accent/30 focus:border-theme-accent"
/>
</div>
<Button size="sm" onClick={buscarHistorial} loading={histLoading}>Buscar</Button>
<button
type="button"
onClick={() => { setHistOpen(false); setHistResults([]); setHistQuery(''); }}
className="p-1.5 rounded-lg text-theme-muted hover:bg-theme-bg"
aria-label="Cerrar búsqueda"
>
<X size={16} />
</button>
</div>
{histResults.length > 0 && (
<div className="mt-2 max-h-56 overflow-y-auto divide-y divide-theme-border rounded-xl border border-theme-border">
<p className="px-3 py-1.5 text-[11px] text-theme-muted bg-theme-bg">
{histResults.length} {histResults.length === 1 ? 'resultado' : 'resultados'} para "{histQuery}"
</p>
{histResults.map((m) => (
<button
key={m.id}
type="button"
onClick={() => irAlMensaje(m)}
className="w-full text-left px-3 py-2 hover:bg-theme-bg transition"
>
<p className="text-xs text-theme-heading break-words">
{resaltar(m.body, histQuery)}
</p>
<p className="text-[10px] text-theme-muted mt-0.5">
{m.mine ? 'Tú' : activeUser.name} · {m.date}
</p>
</button>
))}
</div>
)}
{!histLoading && histQuery.trim().length >= 2 && histResults.length === 0 && (
<p className="mt-2 text-xs text-theme-muted">Sin coincidencias en esta conversación.</p>
)}
</div>
)}
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-2 bg-theme-bg">
{loadingThread ? (
<div className="space-y-3">
@@ -261,9 +418,16 @@ const Chat: FC = () => {
</p>
) : (
messages.map((m) => (
<div key={m.id} className={`flex ${m.mine ? 'justify-end' : 'justify-start'}`}>
<div
key={m.id}
ref={highlightId === m.id ? highlightRef : undefined}
data-msg-id={m.id}
className={`flex ${m.mine ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[75%] rounded-2xl px-3.5 py-2 text-sm shadow-card ${
className={`max-w-[75%] rounded-2xl px-3.5 py-2 text-sm shadow-card transition-shadow ${
highlightId === m.id ? 'ring-2 ring-amber-400' : ''
} ${
m.mine
? 'bg-theme-accent text-theme-inverse rounded-br-md'
: 'bg-theme-surface text-theme-heading border border-theme-border rounded-bl-md'

View File

@@ -1,10 +1,14 @@
import type { FC } from 'react';
import { useEffect, useState, useCallback, useRef } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { RefreshCw, MessageSquare, Phone, Search, User, Bot, Headphones, UserPlus, X, Send, ChevronDown, Heart, Calendar } from 'lucide-react';
import { RefreshCw, MessageSquare, Phone, Search, User, Bot, Headphones, UserPlus, X, Send, ChevronDown, Heart, Calendar, StickyNote, Tag, BarChart3 } from 'lucide-react';
import Layout from '../components/Layout';
import { Card, Button, Input, EmptyState, Skeleton, toast, Badge, Modal } from '../components/ui';
import { odooApi, type WacrmConversation, type WacrmMessage, type Patient } from '../services/odoo';
import { odooApi, type WacrmConversation, type WacrmMessage, type Patient, type WacrmTag } from '../services/odoo';
import { useAuth } from '../lib/auth';
import WacrmMetrics from '../components/wacrm/WacrmMetrics';
import ConversationNotas from '../components/wacrm/ConversationNotas';
import ConversationEtiquetas from '../components/wacrm/ConversationEtiquetas';
interface Member {
id: string;
@@ -39,6 +43,14 @@ const WacrmMessages: FC = () => {
return '';
});
const [filtro, setFiltro] = useState<'todas' | 'mias' | 'sin_asignar' | 'no_leidas'>('todas');
// Vista de la página: bandeja o tablero de métricas (sólo admin/gerencia)
const [vista, setVista] = useState<'conversaciones' | 'metricas'>('conversaciones');
// Panel del detalle: mensajes, notas internas o etiquetas
const [detalleTab, setDetalleTab] = useState<'mensajes' | 'notas' | 'etiquetas'>('mensajes');
const [tagsCatalogo, setTagsCatalogo] = useState<WacrmTag[]>([]);
const [filtroTag, setFiltroTag] = useState<number | ''>('');
const { user } = useAuth();
const puedeVerMetricas = !!user && (user.role === 'admin' || user.role === 'gerencia');
const [searchParams] = useSearchParams();
const convParam = searchParams.get('conv');
@@ -154,6 +166,10 @@ const WacrmMessages: FC = () => {
if (res.status === 'success') {
toast.success(`Sincronizado: ${JSON.stringify(res.result)}`);
await loadConversations();
} else if (res.status === 'busy') {
// La auto-sincronización cada 15 s ya está corriendo
toast.info('Ya hay una sincronización en curso');
await loadConversations();
}
} catch (err) {
toast.error('Error al sincronizar con WACRM');
@@ -163,12 +179,21 @@ const WacrmMessages: FC = () => {
}
};
const loadTags = useCallback(async () => {
try {
const res = await odooApi.getWacrmTags();
if (res.status === 'success') setTagsCatalogo(res.tags);
} catch {
// el catálogo de etiquetas es opcional para la bandeja
}
}, []);
// Carga inicial
useEffect(() => {
let mounted = true;
const init = async () => {
setLoading(true);
await Promise.all([loadConversations(), loadMembers()]);
await Promise.all([loadConversations(), loadMembers(), loadTags()]);
if (mounted) setLoading(false);
};
init();
@@ -261,6 +286,15 @@ const WacrmMessages: FC = () => {
const handleSelect = useCallback((c: WacrmConversation) => {
userSelectedRef.current = true;
setSelected(c);
setDetalleTab('mensajes');
}, []);
/** Refresca la conversación en la lista cuando cambian sus etiquetas. */
const actualizarTagsSeleccion = useCallback((tags: { id: number; name: string; color: string }[]) => {
setSelected((prev) => (prev ? { ...prev, tags } : prev));
setConversations((prev) => prev.map((c) => (
c.external_id === selectedRef.current?.external_id ? { ...c, tags } : c
)));
}, []);
const handleSendReply = async () => {
@@ -320,6 +354,7 @@ const WacrmMessages: FC = () => {
const agentName = (id?: string) => (id ? members.find((m) => m.id === id)?.name : undefined);
const conversationsFiltradas = conversations.filter((c) => {
if (filtroTag !== '' && !(c.tags || []).some((t) => t.id === filtroTag)) return false;
if (filtro === 'mias') return !!currentAgentId && c.assigned_agent_id === currentAgentId;
if (filtro === 'sin_asignar') return !c.assigned_agent_id;
if (filtro === 'no_leidas') return c.unread_count > 0;
@@ -366,6 +401,36 @@ const WacrmMessages: FC = () => {
</div>
</div>
{/* Vista: bandeja o métricas de gerencia */}
<div className="flex flex-wrap gap-1.5 mb-4">
<button
type="button"
onClick={() => setVista('conversaciones')}
className={`inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-full transition ${
vista === 'conversaciones' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
}`}
>
<MessageSquare size={15} /> Conversaciones
</button>
{puedeVerMetricas && (
<button
type="button"
onClick={() => setVista('metricas')}
className={`inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-full transition ${
vista === 'metricas' ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
}`}
title="Tablero de atención de WhatsApp (gerencia)"
>
<BarChart3 size={15} /> Métricas
</button>
)}
</div>
{vista === 'metricas' && puedeVerMetricas ? (
<WacrmMetrics />
) : (
<>
{loading ? (
<Skeleton count={6} className="h-20 w-full" />
) : conversations.length === 0 ? (
@@ -394,6 +459,21 @@ const WacrmMessages: FC = () => {
</button>
))}
</div>
{tagsCatalogo.length > 0 && (
<div className="px-2.5 py-2 border-b border-theme-border shrink-0">
<select
value={filtroTag}
onChange={(e) => setFiltroTag(e.target.value === '' ? '' : parseInt(e.target.value, 10))}
aria-label="Filtrar por etiqueta"
className="w-full rounded-lg border border-theme-border-strong bg-theme-surface px-2 py-1.5 text-xs text-theme-heading"
>
<option value="">Todas las etiquetas</option>
{tagsCatalogo.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
</div>
)}
<Card.Body className="p-0 overflow-y-auto flex-1">
<div className="divide-y">
{conversationsFiltradas.length === 0 && (
@@ -420,6 +500,19 @@ const WacrmMessages: FC = () => {
)}
</div>
<p className="text-xs text-theme-muted mt-1 truncate">{c.last_message_text || 'Sin mensajes'}</p>
{(c.tags || []).length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{(c.tags || []).map((t) => (
<span
key={t.id}
className="rounded-full px-1.5 py-0.5 text-[9px] font-medium text-white"
style={{ backgroundColor: t.color }}
>
{t.name}
</span>
))}
</div>
)}
<div className="flex items-center justify-between mt-1">
<p className="text-[10px] text-theme-muted">{c.last_message_at || c.created_at || ''}</p>
{c.assigned_agent_id && (
@@ -500,6 +593,49 @@ const WacrmMessages: FC = () => {
)}
</div>
)}
{/* Pestañas del detalle: mensajes, notas internas y etiquetas */}
<div className="flex flex-wrap gap-1.5 px-4 py-2 border-b border-theme-border shrink-0">
{([
{ key: 'mensajes', label: 'Mensajes', icon: MessageSquare },
{ key: 'notas', label: 'Notas internas', icon: StickyNote },
{ key: 'etiquetas', label: 'Etiquetas', icon: Tag },
] as const).map((t) => {
const Icono = t.icon;
return (
<button
key={t.key}
type="button"
onClick={() => setDetalleTab(t.key)}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition ${
detalleTab === t.key
? 'bg-theme-accent text-theme-inverse'
: 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
}`}
>
<Icono size={13} /> {t.label}
{t.key === 'etiquetas' && (selected.tags || []).length > 0 && (
<span className="inline-flex items-center justify-center min-w-[16px] h-4 px-1 rounded-full bg-theme-surface/40 text-[10px]">
{(selected.tags || []).length}
</span>
)}
</button>
);
})}
</div>
{detalleTab === 'notas' ? (
<ConversationNotas
conversationId={selected.external_id}
contactName={selected.contact_name}
/>
) : detalleTab === 'etiquetas' ? (
<ConversationEtiquetas
conversationId={selected.external_id}
onTagsChange={actualizarTagsSeleccion}
onCatalogoChange={loadTags}
/>
) : (
<>
<div ref={threadRef} className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.length > 0 && msgHasMore && (
<div className="text-center">
@@ -583,6 +719,8 @@ const WacrmMessages: FC = () => {
<p className="text-xs text-amber-600 mt-2">Selecciona "Responder como..." para poder responder mensajes.</p>
)}
</div>
</>
)}
</>
) : (
<div className="flex-1 flex items-center justify-center">
@@ -638,6 +776,8 @@ const WacrmMessages: FC = () => {
)}
</div>
</Modal>
</>
)}
</Layout>
);
};