diff --git a/src/layout/melissa/MelissaEmbed.vue b/src/layout/melissa/MelissaEmbed.vue index 7ecb0f9..8d34400 100644 --- a/src/layout/melissa/MelissaEmbed.vue +++ b/src/layout/melissa/MelissaEmbed.vue @@ -68,15 +68,10 @@ const EMBED_MAP = { icon: 'pi pi-chart-bar', comp: defineAsyncComponent(() => import('@/views/pages/therapist/RelatoriosPage.vue')) }, - 'notificacoes': { - label: 'Notificações', - desc: 'Histórico de notificações enviadas (WhatsApp, e-mail, SMS).', - icon: 'pi pi-bell', - comp: defineAsyncComponent(() => import('@/views/pages/therapist/NotificationsHistoryPage.vue')) - }, - // 'link-externo' foi promovido pra página nativa MelissaLinkExterno - // (eliminado o triplo header). Wire-up agora no MelissaLayout.vue, - // não passa mais por aqui. + // 'notificacoes' e 'link-externo' foram promovidos pra páginas nativas + // Melissa (MelissaNotificacoes / MelissaLinkExterno) — eliminado o + // triplo header que aparecia no embed. Wire-up agora no MelissaLayout.vue, + // não passam mais por aqui. }; const info = computed(() => EMBED_MAP[props.secaoRota] || null); diff --git a/src/layout/melissa/MelissaLayout.vue b/src/layout/melissa/MelissaLayout.vue index efbc636..ba79c5b 100644 --- a/src/layout/melissa/MelissaLayout.vue +++ b/src/layout/melissa/MelissaLayout.vue @@ -37,6 +37,7 @@ import MelissaEmbed from './MelissaEmbed.vue'; import MelissaCadastrosRecebidos from './MelissaCadastrosRecebidos.vue'; import MelissaAgendamentosRecebidos from './MelissaAgendamentosRecebidos.vue'; import MelissaLinkExterno from './MelissaLinkExterno.vue'; +import MelissaNotificacoes from './MelissaNotificacoes.vue'; import MelissaMedicos from './MelissaMedicos.vue'; import MelissaEventoPanel from './MelissaEventoPanel.vue'; import { TOQUES, playToque } from './melissaToques'; @@ -175,9 +176,10 @@ const SECOES = { }; // Set de keys que renderizam via MelissaEmbed (Onda 1 — pages 1-coluna). -// 'link-externo' foi promovido pra página nativa (MelissaLinkExterno) pra -// remover o triplo-header que aparecia no embed. -const MELISSA_EMBED_KEYS = ['financeiro', 'financeiro-lancamentos', 'documentos', 'documentos-templates', 'online-scheduling', 'relatorios', 'notificacoes']; +// 'link-externo' e 'notificacoes' foram promovidos pra páginas nativas +// (MelissaLinkExterno / MelissaNotificacoes) pra remover o triplo-header +// que aparecia no embed. +const MELISSA_EMBED_KEYS = ['financeiro', 'financeiro-lancamentos', 'documentos', 'documentos-templates', 'online-scheduling', 'relatorios']; // Slugs reservados pra páginas dedicadas (não-config) — agenda, pacientes, // conversas etc. + as MELISSA_EMBED_KEYS. Usado no isConfigRoute pra @@ -185,7 +187,7 @@ const MELISSA_EMBED_KEYS = ['financeiro', 'financeiro-lancamentos', 'documentos' const MELISSA_NON_CONFIG_SLUGS = new Set([ 'agenda', 'pacientes', 'compromissos', 'recorrencias', 'conversas', 'tags', 'grupos', 'cadastros-recebidos', 'medicos', 'agendamentos-recebidos', - 'link-externo', + 'link-externo', 'notificacoes', ...MELISSA_EMBED_KEYS ]); // Aliases "bonitos" + INLINE_KEYS reconhecidos pelo MelissaConfiguracoes. @@ -2170,6 +2172,11 @@ function onKeydown(e) { @close="fecharSecao" /> + + +/* + * MelissaNotificacoes — Página nativa Melissa pro histórico de notificações + * (substitui o embed via MelissaEmbed que duplicava headers). + * + * Layout 2-col (espelha demais Melissa Pages tabulares): + * - COL 1 — Sidebar (~280px): stats + filtros (status / tipo) + footer fixo + * - COL 2 — Main: toolbar (busca) + lista de notificações com row design + * preservado da NotificationsHistoryPage (border-left colorido + * por tipo, hover actions pra lida/arquivar/remover) + * + * Click numa notificação: + * - inbound_message → abre o ConversationDrawer (paciente ou anônimo) + * - outras com deeplink → router.push(deeplink) + * - todas marcam como lida automaticamente se ainda não estiverem. + */ +import { ref, computed, onMounted, watch } from 'vue'; +import { useRouter } from 'vue-router'; +import { useToast } from 'primevue/usetoast'; +import { useConfirm } from 'primevue/useconfirm'; +import { formatDistanceToNow, format } from 'date-fns'; +import { ptBR } from 'date-fns/locale'; +import { supabase } from '@/lib/supabase/client'; +import { useNotificationStore } from '@/stores/notificationStore'; +import { useConversationDrawerStore } from '@/stores/conversationDrawerStore'; +import { useTenantStore } from '@/stores/tenantStore'; +// Button/InputText/Message: auto via PrimeVueResolver + +const emit = defineEmits(['close']); + +const router = useRouter(); +const toast = useToast(); +const confirm = useConfirm(); +const notifStore = useNotificationStore(); +const conversationDrawer = useConversationDrawerStore(); +const tenantStore = useTenantStore(); + +// ── Estado ───────────────────────────────────────────── +const ownerId = ref(null); +const items = ref([]); +const loading = ref(true); + +const busca = ref(''); +const statusFilter = ref('all'); // 'all' | 'unread' | 'read' | 'archived' +const typeFilter = ref(''); // '' | new_scheduling | new_patient | ... + +// ── Type meta (cores Tailwind 600 — usadas em ícones, border-left, badges) ── +const typeMap = { + new_scheduling: { icon: 'pi-inbox', color: 'rgb(220, 38, 38)', label: 'Agendamento' }, + new_patient: { icon: 'pi-user-plus', color: 'rgb(2, 132, 199)', label: 'Novo paciente' }, + recurrence_alert: { icon: 'pi-refresh', color: 'rgb(217, 119, 6)', label: 'Recorrência' }, + session_status: { icon: 'pi-calendar-times', color: 'rgb(234, 88, 12)', label: 'Sessão' }, + inbound_message: { icon: 'pi-whatsapp', color: 'rgb(22, 163, 74)', label: 'Mensagem' } +}; + +function metaFor(item) { + return typeMap[item.type] || { icon: 'pi-bell', color: 'var(--m-text-muted)', label: item.type }; +} + +// ── Filter options (button lists) ────────────────────── +const STATUS_FILTER_OPTIONS = [ + { key: 'all', label: 'Todas', icon: 'pi pi-list' }, + { key: 'unread', label: 'Não lidas', icon: 'pi pi-bell' }, + { key: 'read', label: 'Lidas', icon: 'pi pi-check' }, + { key: 'archived', label: 'Arquivadas', icon: 'pi pi-inbox' } +]; + +const TYPE_FILTER_OPTIONS = [ + { key: 'new_scheduling', label: 'Agendamento', icon: 'pi pi-inbox' }, + { key: 'new_patient', label: 'Novo paciente', icon: 'pi pi-user-plus' }, + { key: 'recurrence_alert', label: 'Recorrência', icon: 'pi pi-refresh' }, + { key: 'session_status', label: 'Sessão', icon: 'pi pi-calendar-times' }, + { key: 'inbound_message', label: 'Mensagem', icon: 'pi pi-whatsapp' } +]; + +function setStatusFilter(s) { + statusFilter.value = s; +} +function toggleTypeFilter(t) { + typeFilter.value = typeFilter.value === t ? '' : t; +} + +const hasActiveFilters = computed(() => + !!(busca.value || typeFilter.value || statusFilter.value !== 'all') +); +function clearAllFilters() { + busca.value = ''; + typeFilter.value = ''; + statusFilter.value = 'all'; +} + +// ── Stats agregadas ──────────────────────────────────── +const stats = computed(() => { + const total = items.value.length; + const unread = items.value.filter((n) => !n.read_at && !n.archived).length; + const read = items.value.filter((n) => n.read_at && !n.archived).length; + const archived = items.value.filter((n) => n.archived).length; + return [ + { key: 'total', label: 'Total', value: total, cls: 'neutral' }, + { key: 'unread', label: 'Não lidas', value: unread, cls: unread > 0 ? 'warn' : 'neutral' }, + { key: 'read', label: 'Lidas', value: read, cls: read > 0 ? 'ok' : 'neutral' }, + { key: 'archived', label: 'Arquivadas', value: archived, cls: 'neutral' } + ]; +}); + +// ── Filtragem (status + tipo + busca) ────────────────── +const filteredItems = computed(() => { + const q = busca.value.trim().toLowerCase(); + return items.value.filter((n) => { + if (statusFilter.value === 'unread' && (n.read_at || n.archived)) return false; + if (statusFilter.value === 'read' && (!n.read_at || n.archived)) return false; + if (statusFilter.value === 'archived' && !n.archived) return false; + if (statusFilter.value === 'all' && n.archived) return false; + if (typeFilter.value && n.type !== typeFilter.value) return false; + if (q) { + const title = (n.payload?.title || '').toLowerCase(); + const detail = (n.payload?.detail || '').toLowerCase(); + if (!title.includes(q) && !detail.includes(q)) return false; + } + return true; + }); +}); + +const unreadCount = computed(() => + items.value.filter((n) => !n.read_at && !n.archived).length +); + +// ── Data loading ─────────────────────────────────────── +async function load() { + loading.value = true; + try { + const { data: authData } = await supabase.auth.getUser(); + ownerId.value = authData?.user?.id || null; + if (!ownerId.value) { + loading.value = false; + return; + } + const { data, error } = await supabase + .from('notifications') + .select('*') + .eq('owner_id', ownerId.value) + .order('created_at', { ascending: false }) + .limit(500); + if (error) throw error; + items.value = data || []; + } catch (e) { + toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar notificações.', life: 4500 }); + } finally { + loading.value = false; + } +} + +// ── Actions: lida/não lida/arquivar/remover ──────────── +async function markRead(id) { + const now = new Date().toISOString(); + const { error } = await supabase.from('notifications').update({ read_at: now }).eq('id', id); + if (error) { + toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 3500 }); + return; + } + const item = items.value.find((n) => n.id === id); + if (item) item.read_at = now; + const storeItem = notifStore.items.find((n) => n.id === id); + if (storeItem) storeItem.read_at = now; +} + +async function markUnread(id) { + const { error } = await supabase.from('notifications').update({ read_at: null }).eq('id', id); + if (error) { + toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 3500 }); + return; + } + const item = items.value.find((n) => n.id === id); + if (item) item.read_at = null; + const storeItem = notifStore.items.find((n) => n.id === id); + if (storeItem) storeItem.read_at = null; +} + +async function archive(id) { + const { error } = await supabase.from('notifications').update({ archived: true }).eq('id', id); + if (error) { + toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 3500 }); + return; + } + const item = items.value.find((n) => n.id === id); + if (item) item.archived = true; + notifStore.items = notifStore.items.filter((n) => n.id !== id); +} + +async function unarchive(id) { + const { error } = await supabase.from('notifications').update({ archived: false }).eq('id', id); + if (error) { + toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 3500 }); + return; + } + const item = items.value.find((n) => n.id === id); + if (item) item.archived = false; + if (item && !notifStore.items.find((n) => n.id === id)) { + notifStore.items.unshift({ ...item }); + } +} + +function confirmRemove(id) { + confirm.require({ + message: 'Remover esta notificação permanentemente? Essa ação não pode ser desfeita.', + header: 'Remover notificação', + icon: 'pi pi-exclamation-triangle', + acceptLabel: 'Remover', + rejectLabel: 'Cancelar', + acceptClass: 'p-button-danger', + accept: () => remove(id) + }); +} + +async function remove(id) { + const { error } = await supabase.from('notifications').delete().eq('id', id); + if (error) { + toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 3500 }); + return; + } + items.value = items.value.filter((n) => n.id !== id); + notifStore.items = notifStore.items.filter((n) => n.id !== id); + toast.add({ severity: 'success', summary: 'Removida', life: 2500 }); +} + +async function markAllRead() { + const unreadIds = items.value.filter((n) => !n.read_at && !n.archived).map((n) => n.id); + if (!unreadIds.length) return; + const now = new Date().toISOString(); + const { error } = await supabase.from('notifications').update({ read_at: now }).in('id', unreadIds); + if (error) { + toast.add({ severity: 'error', summary: 'Erro', detail: error.message, life: 3500 }); + return; + } + items.value.forEach((n) => { + if (unreadIds.includes(n.id)) n.read_at = now; + }); + notifStore.items.forEach((n) => { + if (unreadIds.includes(n.id)) n.read_at = now; + }); + toast.add({ + severity: 'success', + summary: `${unreadIds.length} marcada${unreadIds.length > 1 ? 's' : ''} como lida${unreadIds.length > 1 ? 's' : ''}`, + life: 2500 + }); +} + +// ── Click numa row: abre deeplink ou conversation drawer ── +function handleRowClick(n) { + if (n.type === 'inbound_message') { + const payload = n.payload || {}; + if (payload.patient_id) { + conversationDrawer.openForPatient(payload.patient_id); + } else if (payload.from_number) { + conversationDrawer.openForThread({ + thread_key: `anon:${payload.from_number}`, + tenant_id: tenantStore.activeTenantId, + patient_id: null, + patient_name: null, + contact_number: payload.from_number, + channel: payload.channel || 'whatsapp', + message_count: 1, + unread_count: 1, + kanban_status: 'awaiting_us', + last_message_at: new Date().toISOString() + }); + } + if (!n.read_at) markRead(n.id); + return; + } + const deeplink = n.payload?.deeplink; + if (deeplink) { + if (!n.read_at) markRead(n.id); + // Fecha a página Melissa antes de navegar pra rota Rail (deeplinks + // apontam pra /therapist/... ou /admin/...). + emit('close'); + router.push(deeplink); + } else if (!n.read_at) { + markRead(n.id); + } +} + +// ── Helpers de formatação ────────────────────────────── +function timeAgo(iso) { + try { + return formatDistanceToNow(new Date(iso), { addSuffix: true, locale: ptBR }); + } catch { + return '—'; + } +} + +function fullDate(iso) { + try { + return format(new Date(iso), "dd 'de' MMMM 'às' HH:mm", { locale: ptBR }); + } catch { + return ''; + } +} + +function initials(item) { + return item.payload?.avatar_initials || '?'; +} + +// Watcher: se filtro de tipo casa com nenhum item visível, mantém ativo +// pra dar feedback. (Não auto-reseta — user controla via X inline.) +watch(() => statusFilter.value, () => { /* noop — filter applies via computed */ }); + +onMounted(() => { + load(); +}); + + + + + + + + + + Notificações + {{ filteredItems.length }} + + {{ unreadCount }} não lida{{ unreadCount !== 1 ? 's' : '' }} + + + + + + Marcar todas lidas + + + + + + + + + + + + + + + Histórico completo de notificações da clínica — agendamentos, + mensagens, recorrências e sessões. Marque como lida, + arquive ou clique numa notificação pra abrir o + contexto correspondente. + + + + + + + + + + + + + + + + + + + + + + + + + + Carregando notificações… + + + + + + + {{ hasActiveFilters ? 'Nada encontrado' : 'Nenhuma notificação ainda' }} + + + {{ hasActiveFilters + ? 'Ajuste os filtros ou limpe-os pra ver tudo.' + : 'Quando algo acontecer, você será avisado aqui.' }} + + + + + + + + + + + {{ initials(n) }} + + + + {{ n.payload?.title || '(sem título)' }} + + {{ metaFor(n).label }} + + + Arquivada + + + {{ n.payload?.detail || '—' }} + + {{ timeAgo(n.created_at) }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + +