arreglo de la sincronizacion de WhatsApp: paso de 74 s a 0.15 s (insercion por lotes, sincronizacion incremental por marcas de tiempo, contactos solo los referenciados, poda unicamente en el ciclo completo) + fin del bucle infinito de peticiones en la bandeja y refresco cada 30 s con indicador de hora + aviso inmediato desde el puente de WhatsApp
This commit is contained in:
@@ -6,6 +6,7 @@ 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, type WacrmTag } from '../services/odoo';
|
||||
import { useAuth } from '../lib/auth';
|
||||
import { useAutoRefresh, AutoRefreshBadge } from '../lib/autoRefresh';
|
||||
import WacrmMetrics from '../components/wacrm/WacrmMetrics';
|
||||
import ConversationNotas from '../components/wacrm/ConversationNotas';
|
||||
import ConversationEtiquetas from '../components/wacrm/ConversationEtiquetas';
|
||||
@@ -18,9 +19,35 @@ interface Member {
|
||||
role: string;
|
||||
}
|
||||
|
||||
// El backend devuelve marcas ISO con microsegundos: se recortan a 3 dígitos
|
||||
// para que `new Date()` las entienda en todos los navegadores.
|
||||
const parseMarca = (iso?: string | null): Date | null => {
|
||||
if (!iso) return null;
|
||||
const limpio = iso.replace(/(\.\d{3})\d+/, '$1');
|
||||
const fecha = new Date(limpio);
|
||||
return Number.isNaN(fecha.getTime()) ? null : fecha;
|
||||
};
|
||||
|
||||
const hhmmss = (fecha: Date | null) =>
|
||||
fecha ? fecha.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }) : null;
|
||||
|
||||
// Huella de los campos que la bandeja pinta: sirve para no re-renderizar
|
||||
// cuando el refresco trae exactamente lo mismo.
|
||||
const firmaConversacion = (c: WacrmConversation) => JSON.stringify([
|
||||
c.last_message_at, c.last_message_text, c.unread_count, c.status,
|
||||
c.assigned_agent_id, c.assigned_agent, (c.tags || []).map((t) => t.id),
|
||||
]);
|
||||
|
||||
// Cada cuánto se sincroniza el caché del servidor. Cada ciclo es incremental
|
||||
// (milisegundos), así que 30 s es holgado y deja la bandeja viva.
|
||||
const SYNC_INTERVAL_MS = 30000;
|
||||
|
||||
const WacrmMessages: FC = () => {
|
||||
const [conversations, setConversations] = useState<WacrmConversation[]>([]);
|
||||
const [messages, setMessages] = useState<WacrmMessage[]>([]);
|
||||
// Espejo de `messages` para que `loadMessages` no dependa del estado (ver
|
||||
// comentario en loadMessages).
|
||||
const messagesRef = useRef<WacrmMessage[]>([]);
|
||||
const [msgPage, setMsgPage] = useState(1);
|
||||
const [msgHasMore, setMsgHasMore] = useState(false);
|
||||
const [loadingOlder, setLoadingOlder] = useState(false);
|
||||
@@ -31,7 +58,8 @@ const WacrmMessages: FC = () => {
|
||||
const cercaDelFondoRef = useRef(true);
|
||||
const [members, setMembers] = useState<Member[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
// Hora (del servidor) de la última sincronización del caché con WACRM.
|
||||
const [lastSyncAt, setLastSyncAt] = useState<string | null>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [selected, setSelected] = useState<WacrmConversation | null>(null);
|
||||
const [assignOpen, setAssignOpen] = useState(false);
|
||||
@@ -77,6 +105,10 @@ const WacrmMessages: FC = () => {
|
||||
searchRef.current = search;
|
||||
}, [search]);
|
||||
|
||||
useEffect(() => {
|
||||
messagesRef.current = messages;
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentAgentId && typeof window !== 'undefined') {
|
||||
localStorage.setItem('skeen_current_agent_id', currentAgentId);
|
||||
@@ -88,11 +120,13 @@ const WacrmMessages: FC = () => {
|
||||
const res = await odooApi.getWacrmConversations(searchRef.current);
|
||||
if (res.status === 'success') {
|
||||
setConversations(res.conversations);
|
||||
if (res.last_sync_at) setLastSyncAt(res.last_sync_at);
|
||||
const currentSelected = selectedRef.current;
|
||||
if (currentSelected) {
|
||||
const updated = res.conversations.find((c) => c.external_id === currentSelected.external_id);
|
||||
if (updated && updated.external_id !== currentSelected.external_id) {
|
||||
// Solo actualizar si realmente cambió la referencia (datos nuevos)
|
||||
// La conversación abierta también se refresca (último mensaje, no
|
||||
// leídos, asignación...), pero solo si de verdad cambió algo.
|
||||
if (updated && firmaConversacion(updated) !== firmaConversacion(currentSelected)) {
|
||||
setSelected(updated);
|
||||
}
|
||||
} else if (!userSelectedRef.current && res.conversations.length > 0) {
|
||||
@@ -138,9 +172,10 @@ const WacrmMessages: FC = () => {
|
||||
setMsgHasMore(res.has_more);
|
||||
if (page === 1) {
|
||||
// Merge: conserva el historial antiguo ya cargado (scroll manual)
|
||||
const previos = messagesRef.current;
|
||||
const known = new Set(res.messages.map((m) => m.external_id));
|
||||
const minTs = res.messages.length ? res.messages[0].created_at : '';
|
||||
const older = messages.filter((m) => !known.has(m.external_id) && (m.created_at ?? '') < (minTs ?? ''));
|
||||
const older = previos.filter((m) => !known.has(m.external_id) && (m.created_at ?? '') < (minTs ?? ''));
|
||||
setMessages([...older, ...res.messages]);
|
||||
// Solo baja al final si cambió de conversación o el usuario ya estaba
|
||||
// abajo; si estaba leyendo mensajes anteriores, se respeta su posición.
|
||||
@@ -148,7 +183,7 @@ const WacrmMessages: FC = () => {
|
||||
scrollThreadToBottom();
|
||||
}
|
||||
} else {
|
||||
const prev = messages;
|
||||
const prev = messagesRef.current;
|
||||
const known = new Set(prev.map((m) => m.external_id));
|
||||
const older = res.messages.filter((m) => !known.has(m.external_id));
|
||||
setMessages([...older, ...prev]);
|
||||
@@ -163,7 +198,10 @@ const WacrmMessages: FC = () => {
|
||||
toast.error('Error al cargar mensajes');
|
||||
console.error(err);
|
||||
}
|
||||
}, [messages]);
|
||||
// Sin dependencia de `messages` a propósito: si la tuviera, cada mensaje
|
||||
// nuevo la recrearía y el efecto que la llama volvería a pedir el hilo en
|
||||
// bucle (una petición por render).
|
||||
}, []);
|
||||
|
||||
const loadMembers = useCallback(async () => {
|
||||
try {
|
||||
@@ -176,25 +214,35 @@ const WacrmMessages: FC = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const sync = async () => {
|
||||
// Un ciclo = sincronizar el caché del servidor y volver a leer la bandeja y
|
||||
// el hilo abierto. El hook de auto-refresco se encarga de que nunca haya dos
|
||||
// ciclos a la vez y de que no corra con la pestaña oculta.
|
||||
const refreshAll = useCallback(async () => {
|
||||
try {
|
||||
setSyncing(true);
|
||||
const res = await odooApi.syncWacrm();
|
||||
if (res.status === 'success') {
|
||||
toast.success(`Sincronizado: ${JSON.stringify(res.result)}`);
|
||||
await loadConversations();
|
||||
if (res.result?.last_sync_at) setLastSyncAt(String(res.result.last_sync_at));
|
||||
} 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();
|
||||
// Otro ciclo (esta u otra pestaña) está corriendo: no es un error.
|
||||
console.info('Sincronización WACRM en curso; se reintenta al siguiente ciclo');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Error al sincronizar con WACRM');
|
||||
console.error(err);
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
// Un fallo de sincronización no debe romper la bandeja: se sigue viendo
|
||||
// el caché y se reintenta en el siguiente ciclo.
|
||||
console.error('Error al sincronizar con WACRM', err);
|
||||
}
|
||||
};
|
||||
await loadConversations();
|
||||
const current = selectedRef.current;
|
||||
if (current) await loadMessages(current.external_id);
|
||||
}, [loadConversations, loadMessages]);
|
||||
|
||||
const auto = useAutoRefresh({
|
||||
onRefresh: refreshAll,
|
||||
intervalMs: SYNC_INTERVAL_MS,
|
||||
storageKey: 'skeen_wacrm_autorefresh_paused',
|
||||
});
|
||||
|
||||
const sync = useCallback(() => auto.refreshNow(), [auto]);
|
||||
|
||||
const loadTags = useCallback(async () => {
|
||||
try {
|
||||
@@ -289,17 +337,9 @@ const WacrmMessages: FC = () => {
|
||||
}
|
||||
}, [selected, loadMessages]);
|
||||
|
||||
// Auto-sync cada 15 segundos
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
loadConversations();
|
||||
const currentSelected = selectedRef.current;
|
||||
if (currentSelected) {
|
||||
loadMessages(currentSelected.external_id);
|
||||
}
|
||||
}, 15000);
|
||||
return () => clearInterval(interval);
|
||||
}, [loadConversations, loadMessages]);
|
||||
// El ciclo de sincronización + refresco lo lleva `useAutoRefresh` (ver
|
||||
// `auto` arriba): espera a que termine el anterior, no corre con la pestaña
|
||||
// oculta y se puede pausar desde el indicador.
|
||||
|
||||
const handleSelect = useCallback((c: WacrmConversation) => {
|
||||
userSelectedRef.current = true;
|
||||
@@ -402,7 +442,7 @@ const WacrmMessages: FC = () => {
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative">
|
||||
<select
|
||||
value={currentAgentId}
|
||||
@@ -416,7 +456,18 @@ const WacrmMessages: FC = () => {
|
||||
</select>
|
||||
<ChevronDown size={14} className="absolute right-2.5 top-1/2 -translate-y-1/2 text-theme-muted pointer-events-none" />
|
||||
</div>
|
||||
<Button onClick={sync} loading={syncing}>
|
||||
{/* Indicador discreto: cuándo se sincronizó con WACRM y cuándo se
|
||||
recargó esta bandeja. */}
|
||||
<div className="flex flex-col items-start gap-0.5">
|
||||
<AutoRefreshBadge
|
||||
state={auto}
|
||||
hint={`La bandeja se sincroniza sola cada ${SYNC_INTERVAL_MS / 1000} segundos`}
|
||||
/>
|
||||
<span className="text-[10px] text-theme-muted">
|
||||
Última sincronización: {hhmmss(parseMarca(lastSyncAt)) || '—'}
|
||||
</span>
|
||||
</div>
|
||||
<Button onClick={sync} loading={auto.refreshing}>
|
||||
<RefreshCw size={16} className="mr-2" />
|
||||
Sincronizar
|
||||
</Button>
|
||||
|
||||
@@ -5403,7 +5403,7 @@ export const odooApi = {
|
||||
// ============================================================
|
||||
// WACRM Proxy
|
||||
// ============================================================
|
||||
async syncWacrm(): Promise<{ status: string; result: Record<string, number | string | null> }> {
|
||||
async syncWacrm(): Promise<{ status: string; result: Record<string, unknown> }> {
|
||||
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/wacrm/sync`);
|
||||
return data;
|
||||
},
|
||||
@@ -5420,7 +5420,7 @@ export const odooApi = {
|
||||
return data;
|
||||
},
|
||||
|
||||
async getWacrmConversations(search?: string): Promise<{ status: string; conversations: WacrmConversation[] }> {
|
||||
async getWacrmConversations(search?: string): Promise<{ status: string; conversations: WacrmConversation[]; last_sync_at?: string | null }> {
|
||||
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/wacrm/conversations`, { params: search ? { search } : {} });
|
||||
return data;
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user