diff --git a/src/composables/useConversations.js b/src/composables/useConversations.js index aa1fc16..6c8d896 100644 --- a/src/composables/useConversations.js +++ b/src/composables/useConversations.js @@ -45,6 +45,21 @@ export function useConversations() { let realtimeChannel = null; + // Debounce do refetch disparado por realtime — sem isso, clínica ativa + // recebendo 10 mensagens/min faz 10 SELECT 500 na lista de threads por + // minuto. 300ms agrupa rajadas (digita-rápido, mensagens em sequência) + // sem fazer o user esperar visualmente. A nova mensagem em si vai pro + // threadMessages direto (acima); load() é só pra atualizar contadores + // e preview do thread na lista — pode esperar. + let _loadDebounceTimer = null; + function _scheduleLoad() { + if (_loadDebounceTimer) clearTimeout(_loadDebounceTimer); + _loadDebounceTimer = setTimeout(() => { + _loadDebounceTimer = null; + load(); + }, 300); + } + async function load() { const tenantId = tenantStore.activeTenantId; if (!tenantId) { @@ -90,8 +105,8 @@ export function useConversations() { filter: `tenant_id=eq.${tenantId}` }, (payload) => { - // refetch da lista (view agrega tudo) - load(); + // refetch da lista (view agrega tudo) — debounced + _scheduleLoad(); // se o drawer esta aberto numa thread desta msg, appenda const newMsg = payload.new; if (currentThread.value && messageBelongsToThread(newMsg, currentThread.value)) { @@ -109,7 +124,7 @@ export function useConversations() { filter: `tenant_id=eq.${tenantId}` }, (payload) => { - load(); + _scheduleLoad(); const updated = payload.new; if (currentThread.value && messageBelongsToThread(updated, currentThread.value)) { const idx = threadMessages.value.findIndex((m) => m.id === updated.id); @@ -125,6 +140,12 @@ export function useConversations() { supabase.removeChannel(realtimeChannel); realtimeChannel = null; } + // Cancela refetch agendado — se desmontar entre o trigger e o + // debounce dispara, callback rodaria em ref morta. + if (_loadDebounceTimer) { + clearTimeout(_loadDebounceTimer); + _loadDebounceTimer = null; + } } onUnmounted(() => unsubscribeRealtime()); @@ -263,6 +284,10 @@ export function useConversations() { threadLoading, loadThreadMessages, markThreadRead, - setKanbanStatus + setKanbanStatus, + // Exposto pra evitar que cada consumidor chame supabase.auth.getUser() + // por conta propria — antes, MelissaConversas + composable faziam 2 + // round-trips ao auth no mesmo mount. + currentUserId }; } diff --git a/src/layout/melissa/MelissaConversas.vue b/src/layout/melissa/MelissaConversas.vue index e2cf01f..350fb5b 100644 --- a/src/layout/melissa/MelissaConversas.vue +++ b/src/layout/melissa/MelissaConversas.vue @@ -21,6 +21,8 @@ import { useConversations } from '@/composables/useConversations'; import { useConversationTags } from '@/composables/useConversationTags'; import { useTenantStore } from '@/stores/tenantStore'; import { useConversationDrawerStore } from '@/stores/conversationDrawerStore'; +import MelissaConversasSidebar from './MelissaConversasSidebar.vue'; +import MelissaConversasCard from './MelissaConversasCard.vue'; // Badge/InputText/IconField/InputIcon: auto-import via PrimeVueResolver const emit = defineEmits(['close']); @@ -45,7 +47,7 @@ function toggleDrawer() { drawerOpen.value = !drawerOpen.value; } function fecharDrawer() { drawerOpen.value = false; } // ── Composables (espelhado da CRMConversasPage) ───────────── -const { threads, filteredThreads, byKanban, summary, filters, loading, load, subscribeRealtime, unsubscribeRealtime } = useConversations(); +const { threads, filteredThreads, byKanban, summary, filters, loading, load, subscribeRealtime, unsubscribeRealtime, currentUserId } = useConversations(); const tagsApi = useConversationTags(); const threadTagsMap = ref(new Map()); @@ -59,13 +61,28 @@ function tagsForThread(threadKey) { return ids.map((id) => tagById.value[id]).filter(Boolean); } async function reloadThreadTags() { - const keys = filteredThreads.value.map((t) => t.thread_key); + // Tags pra TODOS os threads (nao so filtered) — antes, mudar de filtro + // fazia tags piscarem (threads novas apareciam sem tags ate o watch + // disparar de novo). Carregar de threads.value uma so vez cobre todos + // os filtros sem refetch. + const keys = threads.value.map((t) => t.thread_key); threadTagsMap.value = await tagsApi.loadForThreads(keys); } -// Atribuição -const currentUserId = ref(null); -supabase.auth.getUser().then(({ data }) => { currentUserId.value = data?.user?.id ?? null; }); +// Debounce de reload de tags — antes, watch em filteredThreads.length +// disparava reloadThreadTags em cada char digitado na busca (ou flick de +// filtro). 200ms agrupa rajadas; tambem cobre o caso de realtime que +// substitui threads.value (load() do composable). +let _tagsReloadTimer = null; +function _scheduleTagsReload() { + if (_tagsReloadTimer) clearTimeout(_tagsReloadTimer); + _tagsReloadTimer = setTimeout(() => { + _tagsReloadTimer = null; + reloadThreadTags(); + }, 200); +} + +// Atribuição (currentUserId vem do useConversations — fonte unica) const mineCount = computed(() => { const uid = currentUserId.value; if (!uid) return 0; @@ -88,14 +105,7 @@ async function loadMemberNames() { } memberNameMap.value = map; } -function assigneeLabel(userId) { - if (!userId) return ''; - const full = memberNameMap.value[userId]; - if (!full) return 'Atribuída'; - const parts = full.trim().split(/\s+/); - if (parts.length === 1) return parts[0].slice(0, 14); - return `${parts[0]} ${parts[parts.length - 1][0]}.`; -} +// assigneeLabel migrou pra MelissaConversasCard (usa props.memberNameMap). // Constantes const KANBAN_COLUMNS = [ @@ -105,37 +115,11 @@ const KANBAN_COLUMNS = [ { key: 'resolved', label: 'Resolvido', icon: 'pi pi-check', color: 'emerald' } ]; -const CHANNEL_OPTIONS = [ - { label: 'Todos', value: null }, - { label: 'WhatsApp', value: 'whatsapp' }, - { label: 'SMS', value: 'sms' }, - { label: 'E-mail', value: 'email' } -]; +// CHANNEL_OPTIONS migrou pra MelissaConversasSidebar (uso unico la). -// Helpers -function fmtRelative(iso) { - if (!iso) return ''; - const d = new Date(iso); - const now = new Date(); - const diff = Math.floor((now - d) / 1000); - if (diff < 60) return 'agora'; - if (diff < 3600) return `${Math.floor(diff / 60)}m`; - if (diff < 86400) return `${Math.floor(diff / 3600)}h`; - if (diff < 604800) return `${Math.floor(diff / 86400)}d`; - return d.toLocaleDateString('pt-BR'); -} -function channelIcon(ch) { - const map = { whatsapp: 'pi-whatsapp', sms: 'pi-comment', email: 'pi-envelope' }; - return map[ch] || 'pi-comment'; -} -function truncate(s, n = 80) { - if (!s) return ''; - const str = String(s).replace(/\s+/g, ' ').trim(); - return str.length > n ? str.slice(0, n - 1) + '…' : str; -} -function contactLabel(thread) { - return thread.patient_name || thread.contact_number || 'Desconhecido'; -} +// Helpers fmtRelative/channelIcon/truncate/contactLabel migraram pra +// MelissaConversasCard (uso unico la). Se aparecer 3o consumidor, +// vale extrair pra utils/formatters.js. function onCardClick(thread) { drawerStore.openForThread(thread); @@ -148,18 +132,8 @@ const carregandoInicial = computed( const unlinkedCount = computed(() => filteredThreads.value.filter((t) => !t.patient_id).length); -// ── "Limpar filtros" global (footer fixo da sidebar) ───────────── -// `filters` é um ref({...}) (vide useConversations.js). No script -// preciso acessar via .value; no template o auto-unwrap cuida. -const hasActiveFilters = computed(() => - !!(filters.value.search || filters.value.unreadOnly || filters.value.assigned || filters.value.channel) -); -function clearAllFilters() { - filters.value.search = ''; - filters.value.unreadOnly = false; - filters.value.assigned = null; - filters.value.channel = null; -} +// hasActiveFilters/clearAllFilters migraram pra MelissaConversasSidebar +// (uso unico no footer "Limpar filtros" do aside). // Popover de Ações (compact) const actionsPopRef = ref(null); @@ -177,22 +151,45 @@ onMounted(async () => { } await load(); subscribeRealtime(); - await Promise.all([tagsApi.loadAllTags(), reloadThreadTags(), loadMemberNames()]); + // reloadThreadTags removido daqui — o watch(threads) abaixo dispara + // automaticamente apos load() resolver. Evita race com currentUserId + // (que tambem resolve async) e duplica fetch. + await Promise.all([tagsApi.loadAllTags(), loadMemberNames()]); }); onBeforeUnmount(() => { if (_mqMobile) _mqMobile.removeEventListener('change', _onMqMobileChange); if (_mqCompact) _mqCompact.removeEventListener('change', _onMqCompactChange); + if (_tagsReloadTimer) { + clearTimeout(_tagsReloadTimer); + _tagsReloadTimer = null; + } unsubscribeRealtime(); }); -// Recarrega tags quando drawer fecha -watch(() => drawerStore.isOpen, (isOpen) => { - if (!isOpen) { reloadThreadTags(); load(); } -}); -watch(() => filteredThreads.value.length, () => { reloadThreadTags(); }); -watch(() => tenantStore.activeTenantId, async () => { - unsubscribeRealtime(); +// Watch em threads (re-assign do array via load) dispara reload de tags +// debounced — substitui o antigo watch(filteredThreads.length) que era +// ineficiente (qualquer mudanca de filtro recarregava todas as tags). +// deep:false porque load() substitui a ref inteira, nao mutate interno. +watch(threads, _scheduleTagsReload, { flush: 'post' }); + +// Recarrega lista quando drawer fecha (status pode ter mudado, msg lida etc). +// await load() antes pra que o watch(threads) acima ja tenha o array novo +// quando reloadThreadTags rodar via debounce. +watch(() => drawerStore.isOpen, async (isOpen) => { + if (isOpen) return; await load(); +}); + +// Token monotonico — se user troca tenant A→B→A rapido, evita fetches +// "antigos" sobrescreverem threads do tenant atual. Declarado fora do +// watch pra ficar acessivel em multiplas invocacoes (cresce monotonicamente). +let _tenantSwitchToken = 0; +watch(() => tenantStore.activeTenantId, async (newTid, oldTid) => { + if (newTid === oldTid) return; + unsubscribeRealtime(); + const myToken = ++_tenantSwitchToken; + await load(); + if (myToken !== _tenantSwitchToken) return; subscribeRealtime(); }); @@ -200,75 +197,79 @@ watch(() => tenantStore.activeTenantId, async () => { diff --git a/src/layout/melissa/MelissaConversasCard.vue b/src/layout/melissa/MelissaConversasCard.vue new file mode 100644 index 0000000..0f8103b --- /dev/null +++ b/src/layout/melissa/MelissaConversasCard.vue @@ -0,0 +1,130 @@ + + + + + diff --git a/src/layout/melissa/MelissaConversasSidebar.vue b/src/layout/melissa/MelissaConversasSidebar.vue new file mode 100644 index 0000000..cbb5fd6 --- /dev/null +++ b/src/layout/melissa/MelissaConversasSidebar.vue @@ -0,0 +1,307 @@ + + + + +