cc7841bd1f
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>
304 lines
11 KiB
JavaScript
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
|
|
};
|
|
}
|