Files
agenciapsilmno/src/composables/useConversations.js
T
Leonardo cc7841bd1f MelissaConversas: a11y + perf tagsForThread + DRY (channelMeta + KANBAN_COLUMNS shared)
A11y no parent:
- aria-label em botoes icon-only do header (Recarregar dinamico, Buscar
  compact, Close); tooltip vira title que SR ignora
- aria-hidden=true em icones decorativos (header title, search input,
  subheader info-circle, kanban col head, empty state, button icons)
- aria-busy reativo no mw-col__body durante loading
- aria-label dinamico no count do kanban ("3 conversas em Urgente")
- aria-expanded + aria-controls no menu mobile button
- aria-label no input de busca
- role=note no subheader explicativo
- :inert="(drawerOpen && isMobile) || null" no <section class="mw-page">
  — focus trap real: drawer aberto torna conteudo de fundo inerte
  (boolean attr via || null pra Vue 3.4 serializar correto)

A11y no Sidebar:
- aria-hidden=true em todos icones decorativos restantes (filter title
  icons, list/bell/user/user-minus, channel icons, filter-slash, etc)

Perf — tagsForThread cacheado:
- Antes era chamado in-template (2x por card, recriava array a cada
  render). Agora tagsByThreadKey computed Map: lookup O(1) por card,
  recompute so quando threadTagsMap ou tagById muda. EMPTY_TAGS frozen
  evita criar arrays novos pra threads sem tags.

DRY — channelMeta + KANBAN_COLUMNS shared:
- src/utils/channelMeta.js (novo): CHANNEL_OPTIONS frozen + channelIcon
  + channelLabel. Antes channelIcon estava em 3 lugares (parent, Sidebar,
  Card); CHANNEL_OPTIONS em 2 (parent, Sidebar). Agora 1.
- useConversations.js: exporta KANBAN_COLUMNS frozen (metadata canonica:
  key + label + icon + color). Antes parent+Sidebar tinham copias locais
  de 8 linhas cada + composable tinha KANBAN_ORDER separado. Agora
  KANBAN_ORDER deriva de KANBAN_COLUMNS.

Drift eliminado: 3 fontes -> 1 pra channelIcon, 2 -> 1 pra
CHANNEL_OPTIONS, 2 -> 1 pra KANBAN_COLUMNS (KANBAN_ORDER ainda interno
ao composable mas derivado).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-07 16:37:46 -03:00

304 lines
11 KiB
JavaScript

/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/composables/useConversations.js
| Data: 2026
| Local: São Carlos/SP — Brasil
|--------------------------------------------------------------------------
| © 2026 — Todos os direitos reservados
|--------------------------------------------------------------------------
*/
import { ref, computed, onUnmounted } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
// Metadata canonica das colunas do kanban — fonte unica consumida pelo
// SFC parent (kanban grid central) e pelo MelissaConversasSidebar
// (resumo "Por status"). Antes, parent + sidebar tinham copias locais
// de KANBAN_COLUMNS e o composable separadamente tinha KANBAN_ORDER.
export const KANBAN_COLUMNS = Object.freeze([
Object.freeze({ key: 'urgent', label: 'Urgente', icon: 'pi pi-exclamation-triangle', color: 'red' }),
Object.freeze({ key: 'awaiting_us', label: 'Aguardando resposta', icon: 'pi pi-inbox', color: 'amber' }),
Object.freeze({ key: 'awaiting_patient', label: 'Aguardando paciente', icon: 'pi pi-hourglass', color: 'blue' }),
Object.freeze({ key: 'resolved', label: 'Resolvido', icon: 'pi pi-check', color: 'emerald' })
]);
const KANBAN_ORDER = KANBAN_COLUMNS.map((c) => c.key);
function sanitizeSearch(raw) {
if (typeof raw !== 'string') return '';
return raw.trim().slice(0, 120).toLowerCase();
}
export function useConversations() {
const tenantStore = useTenantStore();
const threads = ref([]);
const loading = ref(false);
const error = ref(null);
const filters = ref({
search: '',
channel: null, // null = todos
unreadOnly: false,
assigned: null // null = todas | 'me' | 'unassigned' | <uuid>
});
const currentUserId = ref(null);
supabase.auth.getUser().then(({ data }) => { currentUserId.value = data?.user?.id ?? null; });
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) {
threads.value = [];
error.value = new Error('Tenant ativo inválido');
return;
}
loading.value = true;
error.value = null;
try {
const { data, error: qErr } = await supabase
.from('conversation_threads')
.select('*')
.eq('tenant_id', tenantId)
.order('last_message_at', { ascending: false })
.limit(500);
if (qErr) throw qErr;
threads.value = data ?? [];
} catch (err) {
error.value = err;
threads.value = [];
} finally {
loading.value = false;
}
}
function subscribeRealtime() {
const tenantId = tenantStore.activeTenantId;
if (!tenantId) return;
if (realtimeChannel) {
supabase.removeChannel(realtimeChannel);
}
realtimeChannel = supabase
.channel(`conv_msg_tenant_${tenantId}`)
.on(
'postgres_changes',
{
event: 'INSERT',
schema: 'public',
table: 'conversation_messages',
filter: `tenant_id=eq.${tenantId}`
},
(payload) => {
// 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)) {
const alreadyThere = threadMessages.value.some((m) => m.id === newMsg.id);
if (!alreadyThere) threadMessages.value.push(newMsg);
}
}
)
.on(
'postgres_changes',
{
event: 'UPDATE',
schema: 'public',
table: 'conversation_messages',
filter: `tenant_id=eq.${tenantId}`
},
(payload) => {
_scheduleLoad();
const updated = payload.new;
if (currentThread.value && messageBelongsToThread(updated, currentThread.value)) {
const idx = threadMessages.value.findIndex((m) => m.id === updated.id);
if (idx >= 0) threadMessages.value[idx] = updated;
}
}
)
.subscribe();
}
function unsubscribeRealtime() {
if (realtimeChannel) {
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());
const filteredThreads = computed(() => {
const q = sanitizeSearch(filters.value.search);
const assignFilter = filters.value.assigned;
const uid = currentUserId.value;
return threads.value.filter((t) => {
if (filters.value.channel && t.channel !== filters.value.channel) return false;
if (filters.value.unreadOnly && (t.unread_count || 0) === 0) return false;
if (assignFilter === 'me') {
if (!uid || t.assigned_to !== uid) return false;
} else if (assignFilter === 'unassigned') {
if (t.assigned_to) return false;
} else if (assignFilter && typeof assignFilter === 'string') {
if (t.assigned_to !== assignFilter) return false;
}
if (q) {
const name = (t.patient_name || '').toLowerCase();
const num = (t.contact_number || '').toLowerCase();
const body = (t.last_message_body || '').toLowerCase();
if (!name.includes(q) && !num.includes(q) && !body.includes(q)) return false;
}
return true;
});
});
const byKanban = computed(() => {
const map = { urgent: [], awaiting_us: [], awaiting_patient: [], resolved: [] };
for (const t of filteredThreads.value) {
const k = KANBAN_ORDER.includes(t.kanban_status) ? t.kanban_status : 'awaiting_us';
map[k].push(t);
}
return map;
});
const summary = computed(() => ({
total: threads.value.length,
urgent: byKanban.value.urgent.length,
awaiting_us: byKanban.value.awaiting_us.length,
awaiting_patient: byKanban.value.awaiting_patient.length,
resolved: byKanban.value.resolved.length,
unreadTotal: threads.value.reduce((s, t) => s + (t.unread_count || 0), 0)
}));
// Mensagens de uma thread especifica (drawer)
const threadMessages = ref([]);
const threadLoading = ref(false);
const currentThread = ref(null);
function messageBelongsToThread(msg, thread) {
if (!thread || !msg) return false;
if (thread.patient_id) return msg.patient_id === thread.patient_id;
// thread anônima
if (msg.patient_id) return false;
return (
msg.from_number === thread.contact_number ||
msg.to_number === thread.contact_number
);
}
async function loadThreadMessages(thread) {
currentThread.value = thread;
if (!thread) {
threadMessages.value = [];
return;
}
threadLoading.value = true;
try {
let q = supabase
.from('conversation_messages')
.select('*')
.eq('tenant_id', tenantStore.activeTenantId)
.order('created_at', { ascending: true })
.limit(500);
if (thread.patient_id) {
q = q.eq('patient_id', thread.patient_id);
} else {
// anônimo — filtra por from_number ou to_number
q = q.or(`from_number.eq.${thread.contact_number},to_number.eq.${thread.contact_number}`).is('patient_id', null);
}
const { data, error: qErr } = await q;
if (qErr) throw qErr;
threadMessages.value = data ?? [];
} finally {
threadLoading.value = false;
}
}
async function markThreadRead(thread) {
if (!thread) return;
// Marca unread do inbound como lido
const nowIso = new Date().toISOString();
const tenantId = tenantStore.activeTenantId;
let q = supabase
.from('conversation_messages')
.update({ read_at: nowIso })
.eq('tenant_id', tenantId)
.eq('direction', 'inbound')
.is('read_at', null);
if (thread.patient_id) q = q.eq('patient_id', thread.patient_id);
else q = q.eq('from_number', thread.contact_number).is('patient_id', null);
await q;
load();
}
async function setKanbanStatus(thread, newStatus) {
if (!['urgent', 'awaiting_us', 'awaiting_patient', 'resolved'].includes(newStatus)) return;
const tenantId = tenantStore.activeTenantId;
const patch = { kanban_status: newStatus };
if (newStatus === 'resolved') patch.resolved_at = new Date().toISOString();
let q = supabase.from('conversation_messages').update(patch).eq('tenant_id', tenantId);
if (thread.patient_id) q = q.eq('patient_id', thread.patient_id);
else q = q.eq('from_number', thread.contact_number).is('patient_id', null);
await q;
load();
}
return {
threads,
filteredThreads,
byKanban,
summary,
filters,
loading,
error,
load,
subscribeRealtime,
unsubscribeRealtime,
threadMessages,
threadLoading,
loadThreadMessages,
markThreadRead,
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
};
}