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:
2026-10-13 02:00:10 +00:00
parent 6139df6327
commit 90a81ec508
3 changed files with 528 additions and 178 deletions

View File

@@ -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>

View File

@@ -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;
},