2644e60bb6
Sessão 11+: fechamento do CRM de WhatsApp com dois providers (Evolution/Twilio),
sistema de créditos com Asaas/PIX, polimorfismo de telefones/emails, e integração
admin SaaS no /saas/addons existente.
═══════════════════════════════════════════════════════════════════════════
GRUPO 3 — WORKFLOW / CRM (completo)
═══════════════════════════════════════════════════════════════════════════
3.1 Tags · migration conversation_tags + seed de 5 system tags · composable
useConversationTags.js · popover + pills no drawer e nos cards do Kanban.
3.2 Atribuição de conversa a terapeuta · migration 20260421000012 com PK
(tenant_id, thread_key), UPSERT, RLS que valida assignee como membro ativo
do mesmo tenant · view conversation_threads expandida com assigned_to +
assigned_at · composable useConversationAssignment.js · drawer com Select
filtrável + botão "Assumir" · inbox com filtro aside (Todas/Minhas/Não
atribuídas) e chip do responsável em cada card (destaca "Eu" em azul).
3.3 Notas internas · migration conversation_notes · composable + seção
colapsável no drawer · apenas o criador pode editar/apagar (RLS).
3.5 Converter desconhecido em paciente · botão + dialog quick-cadastro ·
"Vincular existente" com Select filter de até 500 pacientes · cria
telefone WhatsApp (vinculado) via upsertWhatsappForExisting.
3.6 Histórico de conversa no prontuário · nova aba "Conversas" em
PatientProntuario.vue · PatientConversationsTab.vue com stats (total /
recebidas / enviadas / primeira / última), SelectButton de filtro, timeline
com bolhas por direção, mídia inline (imagem/áudio/vídeo/doc via signed
URL), indicadores ✓ ✓✓ de delivery, botão "Abrir no CRM".
═══════════════════════════════════════════════════════════════════════════
MARCO A — UNIFICAÇÃO WHATSAPP (dois providers mutuamente exclusivos)
═══════════════════════════════════════════════════════════════════════════
- Página chooser ConfiguracoesWhatsappChooserPage.vue com 2 cards (Pessoal/
Oficial), deactivate via edge function deactivate-notification-channel
- send-whatsapp-message refatorada com roteamento por provider; Twilio deduz
1 crédito antes do envio e refunda em falha
- Paridade Twilio (novo): módulo compartilhado supabase/functions/_shared/
whatsapp-hooks.ts com lógica provider-agnóstica (opt-in, opt-out, auto-
reply, schedule helpers em TZ São Paulo, makeTwilioCreditedSendFn que
envolve envio em dedução atômica + rollback). Consumido por Evolution E
Twilio inbound. Evolution refatorado (~290 linhas duplicadas removidas).
- Bucket privado whatsapp-media · decrypt via Evolution getBase64From
MediaMessage · upload com path tenant/yyyy/mm · signed URLs on-demand
═══════════════════════════════════════════════════════════════════════════
MARCO B — SISTEMA DE CRÉDITOS WHATSAPP + ASAAS
═══════════════════════════════════════════════════════════════════════════
Banco:
- Migration 20260421000007_whatsapp_credits (4 tabelas: balance,
transactions, packages, purchases) + RPCs add_whatsapp_credits e
deduct_whatsapp_credits (atômicas com SELECT FOR UPDATE)
- Migration 20260421000013_tenant_cpf_cnpj (coluna em tenants com CHECK
de 11 ou 14 dígitos)
Edge functions:
- create-whatsapp-credit-charge · Asaas v3 (sandbox + prod) · PIX com
QR code · getOrCreateAsaasCustomer patcha customer existente com CPF
quando está faltando
- asaas-webhook · recebe PAYMENT_RECEIVED/CONFIRMED e credita balance
Frontend (tenant):
- Página /configuracoes/creditos-whatsapp com saldo + loja + histórico
- Dialog de confirmação com CPF/CNPJ (validação via isValidCPF/CNPJ de
utils/validators, formatação on-blur, pré-fill de tenants.cpf_cnpj,
persiste no primeiro uso) · fallback sandbox 24971563792 REMOVIDO
- Composable useWhatsappCredits extrai erros amigáveis via
error.context.json()
Frontend (SaaS admin):
- Em /saas/addons (reuso do pattern existente, não criou página paralela):
- Aba 4 "Pacotes WhatsApp" — CRUD whatsapp_credit_packages com DataTable,
toggle is_active inline, dialog de edição com validação
- Aba 5 "Topup WhatsApp" — tenant Select com saldo ao vivo · RPC
add_whatsapp_credits com p_admin_id = auth.uid() (auditoria) · histórico
das últimas 20 transações topup/adjustment/refund
═══════════════════════════════════════════════════════════════════════════
GRUPO 2 — AUTOMAÇÃO
═══════════════════════════════════════════════════════════════════════════
2.3 Auto-reply · conversation_autoreply_settings + conversation_autoreply_
log · 3 modos de schedule (agenda das regras semanais, business_hours
custom, custom_window) · cooldown por thread · respeita opt-out · agora
funciona em Evolution E Twilio (hooks compartilhados)
2.4 Lembretes de sessão · conversation_session_reminders_settings +
_logs · edge send-session-reminders (cron) · janelas 24h e 2h antes ·
Twilio deduz crédito com rollback em falha
═══════════════════════════════════════════════════════════════════════════
GRUPO 5 — COMPLIANCE (LGPD Art. 18 §2)
═══════════════════════════════════════════════════════════════════════════
5.2 Opt-out · conversation_optouts + conversation_optout_keywords (10 system
seed + custom por tenant) · detecção por regex word-boundary e normalização
(lowercase + strip acentos + pontuação) · ack automático (deduz crédito em
Twilio) · opt-in via "voltar", "retornar", "reativar", "restart" ·
página /configuracoes/conversas-optouts com CRUD de keywords
═══════════════════════════════════════════════════════════════════════════
REFACTOR POLIMÓRFICO — TELEFONES + EMAILS
═══════════════════════════════════════════════════════════════════════════
- contact_types + contact_phones (entity_type + entity_id) — migration
20260421000008 · contact_email_types + contact_emails — 20260421000011
- Componentes ContactPhonesEditor.vue e ContactEmailsEditor.vue (add/edit/
remove com confirm, primary selector, WhatsApp linked badge)
- Composables useContactPhones.js + useContactEmails.js com
unsetOtherPrimaries() e validação
- Trocado em PatientsCadastroPage.vue e MedicosPage.vue (removidos campos
legados telefone/telefone_alternativo e email_principal/email_alternativo)
- Migration retroativa v2 (20260421000010) detecta conversation_messages
e cria/atualiza phone como WhatsApp vinculado
═══════════════════════════════════════════════════════════════════════════
POLIMENTO VISUAL + INFRA
═══════════════════════════════════════════════════════════════════════════
- Skeletons simplificados no dashboard do terapeuta
- Animações fade-up com stagger via [--delay:Xms] (fix specificity sobre
.dash-card box-shadow transition)
- ConfirmDialog com group="conversation-drawer" (evita montagem duplicada)
- Image preview PrimeVue com botão de download injetado via MutationObserver
(fetch + blob para funcionar cross-origin)
- Áudio/vídeo com preload="metadata" e controles de velocidade do browser
- friendlySendError() mapeia códigos do edge pra mensagens pt-BR via
error.context.json()
- Teleport #cfg-page-actions para ações globais de Configurações
- Brotli/Gzip + auto-import Vue/PrimeVue + bundle analyzer
- AppLayout consolidado (removidas duplicatas por área) + RouterPassthrough
- Removido console.trace debug que estava em watch de router e queries
Supabase (degradava perf pra todos)
- Realtime em conversation_messages via publication supabase_realtime
- Notifier global flutuante com beep + toggle mute (4 camadas: badge +
sino + popup + browser notification)
═══════════════════════════════════════════════════════════════════════════
MIGRATIONS NOVAS (13)
═══════════════════════════════════════════════════════════════════════════
20260420000001_patient_intake_invite_info_rpc
20260420000002_audit_logs_lgpd
20260420000003_audit_logs_unified_view
20260420000004_lgpd_export_patient_rpc
20260420000005_conversation_messages
20260420000005_search_global_rpc
20260420000006_conv_messages_notifications
20260420000007_notif_channels_saas_admin_insert
20260420000008_conv_messages_realtime
20260420000009_conv_messages_delivery_status
20260421000001_whatsapp_media_bucket
20260421000002_conversation_notes
20260421000003_conversation_tags
20260421000004_conversation_autoreply
20260421000005_conversation_optouts
20260421000006_session_reminders
20260421000007_whatsapp_credits
20260421000008_contact_phones
20260421000009_retroactive_whatsapp_link
20260421000010_retroactive_whatsapp_link_v2
20260421000011_contact_emails
20260421000012_conversation_assignments
20260421000013_tenant_cpf_cnpj
═══════════════════════════════════════════════════════════════════════════
EDGE FUNCTIONS NOVAS / MODIFICADAS
═══════════════════════════════════════════════════════════════════════════
Novas:
- _shared/whatsapp-hooks.ts (módulo compartilhado)
- asaas-webhook
- create-whatsapp-credit-charge
- deactivate-notification-channel
- evolution-webhook-provision
- evolution-whatsapp-inbound
- get-intake-invite-info
- notification-webhook
- send-session-reminders
- send-whatsapp-message
- submit-patient-intake
- twilio-whatsapp-inbound
═══════════════════════════════════════════════════════════════════════════
FRONTEND — RESUMO
═══════════════════════════════════════════════════════════════════════════
Composables novos: useAddonExtrato, useAuditoria, useAutoReplySettings,
useClinicKPIs, useContactEmails, useContactPhones, useConversationAssignment,
useConversationNotes, useConversationOptouts, useConversationTags,
useConversations, useLgpdExport, useSessionReminders, useWhatsappCredits
Stores: conversationDrawerStore
Componentes novos: ConversationDrawer, GlobalInboundNotifier, GlobalSearch,
ContactEmailsEditor, ContactPhonesEditor
Páginas novas: CRMConversasPage, PatientConversationsTab, AddonsExtratoPage,
AuditoriaPage, NotificationsHistoryPage, ConfiguracoesWhatsappChooserPage,
ConfiguracoesConversasAutoreplyPage, ConfiguracoesConversasOptoutsPage,
ConfiguracoesConversasTagsPage, ConfiguracoesCreditosWhatsappPage,
ConfiguracoesLembretesSessaoPage
Utils novos: addonExtratoExport, auditoriaExport, excelExport,
lgpdExportFormats
Páginas existentes alteradas: ClinicDashboard, PatientsCadastroPage,
PatientCadastroDialog, PatientsListPage, MedicosPage, PatientProntuario,
ConfiguracoesWhatsappPage, SaasWhatsappPage, ConfiguracoesRecursosExtrasPage,
ConfiguracoesPage, AgendaTerapeutaPage, AgendaClinicaPage, NotificationItem,
NotificationDrawer, AppLayout, AppTopbar, useMenuBadges,
patientsRepository, SaasAddonsPage (aba 4 + 5 WhatsApp)
Routes: routes.clinic, routes.configs, routes.therapist atualizados
Menus: clinic.menu, therapist.menu, saas.menu atualizados
═══════════════════════════════════════════════════════════════════════════
NOTAS
- Após subir, rodar supabase functions serve --no-verify-jwt
--env-file supabase/functions/.env pra carregar o módulo _shared
- WHATSAPP_SETUP.md reescrito (~400 linhas) com setup completo dos 3
providers + troubleshooting + LGPD
- HANDOFF.md atualizado com estado atual e próximos passos
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
841 lines
26 KiB
Vue
841 lines
26 KiB
Vue
<!--
|
|
|--------------------------------------------------------------------------
|
|
| Agência PSI
|
|
|--------------------------------------------------------------------------
|
|
| Criado e desenvolvido por Leonardo Nohama
|
|
|
|
|
| Tecnologia aplicada à escuta.
|
|
| Estrutura para o cuidado.
|
|
|
|
|
| Arquivo: src/layout/AppTopbar.vue
|
|
| Data: 2026
|
|
| Local: São Carlos/SP — Brasil
|
|
|--------------------------------------------------------------------------
|
|
| © 2026 — Todos os direitos reservados
|
|
|--------------------------------------------------------------------------
|
|
-->
|
|
<script setup>
|
|
import { computed, nextTick, onMounted, provide, ref, watch } from 'vue';
|
|
import { useRoute, useRouter } from 'vue-router';
|
|
|
|
import { useLayout } from '@/layout/composables/layout';
|
|
import { useToast } from 'primevue/usetoast';
|
|
|
|
import { supabase } from '@/lib/supabase/client';
|
|
import { useEntitlementsStore } from '@/stores/entitlementsStore';
|
|
import { useTenantStore } from '@/stores/tenantStore';
|
|
|
|
import { useRoleGuard } from '@/composables/useRoleGuard';
|
|
const { canSee } = useRoleGuard();
|
|
|
|
import { useAjuda } from '@/composables/useAjuda';
|
|
const { openDrawer: openAjudaDrawer, closeDrawer: closeAjudaDrawer, drawerOpen: ajudaDrawerOpen } = useAjuda();
|
|
|
|
import NotificationDrawer from '@/components/notifications/NotificationDrawer.vue';
|
|
import GlobalSearch from '@/components/search/GlobalSearch.vue';
|
|
import { useNotifications } from '@/composables/useNotifications';
|
|
import { useNotificationStore } from '@/stores/notificationStore';
|
|
const notificationStore = useNotificationStore();
|
|
useNotifications();
|
|
function toggleAjuda() {
|
|
ajudaDrawerOpen.value ? closeAjudaDrawer() : openAjudaDrawer();
|
|
}
|
|
|
|
import { useUserSettingsPersistence } from '@/composables/useUserSettingsPersistence';
|
|
import { applyThemeEngine } from '@/theme/theme.options';
|
|
|
|
import { fetchAllNotices } from '@/features/notices/noticeService';
|
|
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore';
|
|
|
|
const toast = useToast();
|
|
const entitlementsStore = useEntitlementsStore();
|
|
const tenantStore = useTenantStore();
|
|
|
|
const { toggleMenu, toggleDarkMode, isDarkTheme, layoutConfig, changeMenuMode, isRailMobile } = useLayout();
|
|
const router = useRouter();
|
|
const route = useRoute();
|
|
|
|
const planBtn = ref(null);
|
|
|
|
/* ----------------------------
|
|
Persistência
|
|
----------------------------- */
|
|
const { init: initUserSettings, queuePatch } = useUserSettingsPersistence();
|
|
provide('queueUserSettingsPatch', queuePatch);
|
|
|
|
/* ----------------------------
|
|
Contexto (UID/Email/Tenant)
|
|
----------------------------- */
|
|
const sessionUid = ref(null);
|
|
const sessionEmail = ref(null);
|
|
|
|
async function loadSessionIdentity() {
|
|
try {
|
|
const { data, error } = await supabase.auth.getUser();
|
|
if (error) throw error;
|
|
sessionUid.value = data?.user?.id || null;
|
|
sessionEmail.value = data?.user?.email || null;
|
|
} catch (e) {
|
|
sessionUid.value = null;
|
|
sessionEmail.value = null;
|
|
console.warn('[Topbar][identity] falhou:', e?.message || e);
|
|
}
|
|
}
|
|
|
|
const tenantId = computed(() => tenantStore.activeTenantId || null);
|
|
|
|
// ✅ Admin Dev
|
|
const ctxItems = computed(() => {
|
|
const items = [];
|
|
// ids (sempre úteis pra debug)
|
|
if (tenantId.value) items.push({ k: 'Tenant', v: tenantId.value });
|
|
if (sessionUid.value) items.push({ k: 'UID', v: sessionUid.value });
|
|
|
|
return items;
|
|
});
|
|
|
|
/* ----------------------------
|
|
Fonte da verdade: DOM
|
|
----------------------------- */
|
|
function isDarkNow() {
|
|
return document.documentElement.classList.contains('app-dark');
|
|
}
|
|
|
|
function setDarkMode(shouldBeDark) {
|
|
const now = isDarkNow();
|
|
if (shouldBeDark !== now) toggleDarkMode();
|
|
}
|
|
|
|
async function waitForDarkFlip(before, timeoutMs = 900) {
|
|
const start = performance.now();
|
|
while (performance.now() - start < timeoutMs) {
|
|
await nextTick();
|
|
await new Promise((r) => requestAnimationFrame(r));
|
|
const now = isDarkNow();
|
|
if (now !== before) return now;
|
|
}
|
|
return isDarkNow();
|
|
}
|
|
|
|
async function loadAndApplyUserSettings() {
|
|
try {
|
|
const { data: u, error: uErr } = await supabase.auth.getUser();
|
|
if (uErr) throw uErr;
|
|
const uid = u?.user?.id;
|
|
if (!uid) return;
|
|
|
|
const { data: settings, error } = await supabase.from('user_settings').select('theme_mode, preset, primary_color, surface_color, menu_mode').eq('user_id', uid).maybeSingle();
|
|
|
|
if (error) throw error;
|
|
if (!settings) return;
|
|
|
|
// 1) dark/light (DOM é a fonte da verdade)
|
|
if (settings.theme_mode) setDarkMode(settings.theme_mode === 'dark');
|
|
|
|
// 2) layoutConfig
|
|
if (settings.preset) layoutConfig.preset = settings.preset;
|
|
if (settings.primary_color) layoutConfig.primary = settings.primary_color;
|
|
if (settings.surface_color) layoutConfig.surface = settings.surface_color;
|
|
if (settings.menu_mode) layoutConfig.menuMode = settings.menu_mode;
|
|
|
|
// 3) aplica engine UMA vez
|
|
applyThemeEngine(layoutConfig);
|
|
|
|
// 4) persiste no localStorage para carregamento instantâneo no próximo boot
|
|
try {
|
|
localStorage.setItem('ui_theme_config', JSON.stringify({
|
|
preset: layoutConfig.preset,
|
|
primary: layoutConfig.primary,
|
|
surface: layoutConfig.surface,
|
|
menuMode: layoutConfig.menuMode
|
|
}));
|
|
} catch {}
|
|
|
|
// ✅ IMPORTANTE:
|
|
// changeMenuMode NÃO é só "setar menuMode".
|
|
// Ele reseta estados do sidebar/overlay/mobile e previne drift.
|
|
// No layout Rail, não deve ser chamado — ele não usa menuMode.
|
|
if (layoutConfig.variant !== 'rail') {
|
|
try {
|
|
changeMenuMode(layoutConfig.menuMode);
|
|
} catch (e) {
|
|
try {
|
|
changeMenuMode({ value: layoutConfig.menuMode });
|
|
} catch {}
|
|
console.warn('[Topbar][bootstrap] changeMenuMode falhou:', e?.message || e);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('[Topbar][bootstrap] erro:', e?.message || e);
|
|
}
|
|
}
|
|
|
|
async function toggleDarkAndPersistSilently() {
|
|
try {
|
|
const before = isDarkNow();
|
|
toggleDarkMode();
|
|
const after = await waitForDarkFlip(before);
|
|
const theme_mode = after ? 'dark' : 'light';
|
|
try { localStorage.setItem('ui_theme_mode', theme_mode); } catch {}
|
|
await queuePatch({ theme_mode }, { flushNow: true });
|
|
} catch (e) {
|
|
console.error('[Topbar][theme] falhou:', e?.message || e);
|
|
}
|
|
}
|
|
|
|
/* ----------------------------
|
|
Plano (DEV) — popup menu
|
|
----------------------------- */
|
|
const trocandoPlano = ref(false);
|
|
|
|
const enablePlanToggle = computed(() => {
|
|
const flag = String(import.meta.env?.VITE_ENABLE_PLAN_TOGGLE || '').toLowerCase();
|
|
return Boolean(import.meta.env?.DEV) || flag === 'true';
|
|
});
|
|
|
|
const showPlanDevMenu = computed(() => {
|
|
return canSee('settings.view') && enablePlanToggle.value;
|
|
});
|
|
|
|
const ctxMenu = ref();
|
|
const ctxMenuModel = computed(() =>
|
|
ctxItems.value.length
|
|
? ctxItems.value.map((it) => ({
|
|
label: `${it.k}: ${it.v}`,
|
|
icon: it.k === 'Tenant' ? 'pi pi-building' : 'pi pi-user'
|
|
}))
|
|
: [{ label: 'Sem contexto', icon: 'pi pi-info-circle', disabled: true }]
|
|
);
|
|
function openCtxMenu(event) {
|
|
ctxMenu.value?.toggle?.(event);
|
|
}
|
|
|
|
const planMenu = ref();
|
|
const planMenuLoading = ref(false);
|
|
const planMenuTarget = ref(null); // 'therapist' | 'clinic' | null
|
|
const planMenuSub = ref(null); // subscription ativa (obj)
|
|
const planMenuPlans = ref([]); // plans ativos do target
|
|
|
|
async function getMyUserId() {
|
|
const { data, error } = await supabase.auth.getUser();
|
|
if (error) throw error;
|
|
const uid = data?.user?.id;
|
|
if (!uid) throw new Error('Sessão inválida (sem user).');
|
|
return uid;
|
|
}
|
|
|
|
// therapist subscription: user_id — sem filtro de tenant_id (pode estar preenchido)
|
|
async function getActiveTherapistSubscription() {
|
|
const uid = await getMyUserId();
|
|
|
|
const { data, error } = await supabase.from('subscriptions').select('id, tenant_id, user_id, plan_id, status, updated_at').eq('user_id', uid).order('updated_at', { ascending: false }).limit(10);
|
|
|
|
if (error) throw error;
|
|
|
|
const list = data || [];
|
|
if (!list.length) return null;
|
|
|
|
const priority = (st) => {
|
|
const s = String(st || '').toLowerCase();
|
|
if (s === 'active') return 1;
|
|
if (s === 'trialing') return 2;
|
|
if (s === 'past_due') return 3;
|
|
if (s === 'unpaid') return 4;
|
|
if (s === 'incomplete') return 5;
|
|
if (s === 'canceled' || s === 'cancelled') return 9;
|
|
return 8;
|
|
};
|
|
|
|
return list.slice().sort((a, b) => {
|
|
const pa = priority(a?.status);
|
|
const pb = priority(b?.status);
|
|
if (pa !== pb) return pa - pb;
|
|
return new Date(b?.updated_at || 0) - new Date(a?.updated_at || 0);
|
|
})[0];
|
|
}
|
|
|
|
async function getActiveClinicSubscription() {
|
|
const tid = tenantId.value;
|
|
if (!tid) return null;
|
|
|
|
const { data, error } = await supabase.from('subscriptions').select('id, tenant_id, user_id, plan_id, status, updated_at').eq('tenant_id', tid).eq('status', 'active').order('updated_at', { ascending: false }).limit(1).maybeSingle();
|
|
|
|
if (error) throw error;
|
|
return data || null;
|
|
}
|
|
|
|
async function listActivePlansByTarget(target) {
|
|
const { data, error } = await supabase.from('plans').select('id, key, target, is_active').eq('target', target).eq('is_active', true).order('key', { ascending: true });
|
|
|
|
if (error) throw error;
|
|
return data || [];
|
|
}
|
|
|
|
async function refreshEntitlementsAfterToggle(target) {
|
|
// ✅ aqui NÃO dá pra usar invalidate geral, porque precisamos dos dois caches
|
|
// mas durante toggle, é mais seguro forçar recarga do escopo que foi alterado.
|
|
|
|
if (target === 'clinic') {
|
|
const tid = tenantId.value;
|
|
if (!tid) return;
|
|
await entitlementsStore.loadForTenant(tid, { force: true });
|
|
return;
|
|
}
|
|
|
|
// therapist
|
|
const uid = await getMyUserId();
|
|
await entitlementsStore.loadForUser(uid, { force: true });
|
|
}
|
|
|
|
/**
|
|
* ✅ Resolve a subscription ativa levando em conta a área da rota atual.
|
|
*
|
|
* Áreas de clínica (/admin, /supervisor) → contexto tenant_id (clinic)
|
|
* Demais áreas (/therapist, /editor, /portal, etc.) → contexto user_id (pessoal)
|
|
*
|
|
* Isso evita que um editor que também é membro de uma clínica veja o plano
|
|
* da clínica no botão DEV em vez do seu próprio plano.
|
|
*/
|
|
async function resolveActiveSubscriptionContext() {
|
|
const path = route.path || '';
|
|
const isClinicContext = path.startsWith('/admin') || path.startsWith('/supervisor');
|
|
|
|
if (isClinicContext && tenantId.value) {
|
|
const clinicSub = await getActiveClinicSubscription();
|
|
if (clinicSub) return { sub: clinicSub, target: 'clinic' };
|
|
}
|
|
|
|
const therapistSub = await getActiveTherapistSubscription();
|
|
if (therapistSub) return { sub: therapistSub, target: 'therapist' };
|
|
|
|
// último fallback: clinic (caso não-clínica sem sub pessoal)
|
|
if (tenantId.value) {
|
|
const clinicSub = await getActiveClinicSubscription();
|
|
return { sub: clinicSub || null, target: clinicSub ? 'clinic' : null };
|
|
}
|
|
|
|
return { sub: null, target: null };
|
|
}
|
|
|
|
function normalizeKey(k) {
|
|
return String(k || '').trim();
|
|
}
|
|
|
|
// free primeiro, depois o resto por key
|
|
function sortPlansSmart(plans) {
|
|
const arr = [...(plans || [])];
|
|
arr.sort((a, b) => {
|
|
const ak = normalizeKey(a?.key).toLowerCase();
|
|
const bk = normalizeKey(b?.key).toLowerCase();
|
|
|
|
const aIsFree = ak.endsWith('_free') || ak === 'free';
|
|
const bIsFree = bk.endsWith('_free') || bk === 'free';
|
|
if (aIsFree && !bIsFree) return -1;
|
|
if (!aIsFree && bIsFree) return 1;
|
|
|
|
return ak.localeCompare(bk);
|
|
});
|
|
return arr;
|
|
}
|
|
|
|
async function loadPlanMenuData() {
|
|
planMenuLoading.value = true;
|
|
try {
|
|
const { sub, target } = await resolveActiveSubscriptionContext();
|
|
planMenuSub.value = sub;
|
|
planMenuTarget.value = target;
|
|
|
|
if (!sub?.id || !target) {
|
|
planMenuPlans.value = [];
|
|
return;
|
|
}
|
|
|
|
const plans = await listActivePlansByTarget(target);
|
|
planMenuPlans.value = sortPlansSmart(plans);
|
|
} finally {
|
|
planMenuLoading.value = false;
|
|
}
|
|
}
|
|
|
|
const planMenuModel = computed(() => {
|
|
const sub = planMenuSub.value;
|
|
const target = planMenuTarget.value;
|
|
const plans = planMenuPlans.value || [];
|
|
|
|
if (!sub?.id || !target) {
|
|
return [
|
|
{ label: 'Sem assinatura ativa', icon: 'pi pi-exclamation-triangle', disabled: true },
|
|
{ label: 'Não encontrei subscription ativa nem para therapist (user_id) nem para clinic (tenant_id).', disabled: true }
|
|
];
|
|
}
|
|
|
|
const currentPlanId = String(sub.plan_id || '');
|
|
|
|
const header = {
|
|
label: `Planos (${target})`,
|
|
icon: target === 'therapist' ? 'pi pi-user' : 'pi pi-building',
|
|
disabled: true
|
|
};
|
|
|
|
const subInfo = {
|
|
label: `Sub: ${String(sub.id).slice(0, 8)}… • Atual: ${String(currentPlanId).slice(0, 8)}…`,
|
|
icon: 'pi pi-info-circle',
|
|
disabled: true
|
|
};
|
|
|
|
const items = [];
|
|
let insertedSeparator = false;
|
|
|
|
plans.forEach((p) => {
|
|
const isCurrent = String(p.id) === currentPlanId;
|
|
const keyLower = String(p.key || '').toLowerCase();
|
|
const isFree = keyLower.endsWith('_free') || keyLower === 'free';
|
|
|
|
items.push({
|
|
label: isCurrent ? `${p.key} (atual)` : p.key,
|
|
icon: isCurrent ? 'pi pi-check' : isFree ? 'pi pi-star' : 'pi pi-circle',
|
|
disabled: isCurrent || planMenuLoading.value || trocandoPlano.value,
|
|
command: async () => {
|
|
await changePlanTo(p.id, p.key, target);
|
|
}
|
|
});
|
|
|
|
if (!insertedSeparator && isFree) {
|
|
items.push({ separator: true });
|
|
insertedSeparator = true;
|
|
}
|
|
});
|
|
|
|
if (items.length && items[items.length - 1]?.separator) items.pop();
|
|
|
|
if (!plans.length) {
|
|
return [header, subInfo, { separator: true }, { label: 'Nenhum plano ativo encontrado', icon: 'pi pi-info-circle', disabled: true }];
|
|
}
|
|
|
|
return [header, subInfo, { separator: true }, ...items];
|
|
});
|
|
|
|
async function openPlanMenu(event) {
|
|
if (!showPlanDevMenu.value) return;
|
|
|
|
try {
|
|
await loadPlanMenuData();
|
|
} catch (err) {
|
|
console.error('[PLANO][DEV menu] erro:', err?.message || err);
|
|
toast.add({
|
|
severity: 'error',
|
|
summary: 'Erro ao carregar planos',
|
|
detail: err?.message || 'Falha desconhecida.',
|
|
life: 5200
|
|
});
|
|
}
|
|
|
|
const anchorEl = planBtn.value?.$el || event?.currentTarget || event?.target;
|
|
if (!anchorEl) {
|
|
planMenu.value?.toggle?.(event);
|
|
return;
|
|
}
|
|
|
|
planMenu.value?.show?.({ currentTarget: anchorEl });
|
|
}
|
|
|
|
async function changePlanTo(newPlanId, newPlanKey, target) {
|
|
if (trocandoPlano.value) return;
|
|
trocandoPlano.value = true;
|
|
|
|
try {
|
|
const sub = planMenuSub.value;
|
|
if (!sub?.id) throw new Error('Subscription inválida.');
|
|
|
|
const { error: rpcError } = await supabase.rpc('change_subscription_plan', {
|
|
p_subscription_id: sub.id,
|
|
p_new_plan_id: newPlanId
|
|
});
|
|
if (rpcError) throw rpcError;
|
|
|
|
planMenuSub.value = { ...sub, plan_id: newPlanId };
|
|
|
|
// ✅ recarrega o escopo certo (tenant ou user)
|
|
await refreshEntitlementsAfterToggle(target);
|
|
|
|
toast.add({
|
|
severity: 'success',
|
|
summary: 'Plano alterado (DEV)',
|
|
detail: `${String(newPlanKey).toUpperCase()} aplicado (${target})`,
|
|
life: 3200
|
|
});
|
|
} catch (err) {
|
|
console.error('[PLANO] Erro ao trocar:', err?.message || err);
|
|
toast.add({
|
|
severity: 'error',
|
|
summary: 'Erro ao trocar plano',
|
|
detail: err?.message || 'Falha desconhecida.',
|
|
life: 6000
|
|
});
|
|
} finally {
|
|
trocandoPlano.value = false;
|
|
}
|
|
}
|
|
|
|
/* ----------------------------
|
|
SaaS — indicador de avisos ativos
|
|
----------------------------- */
|
|
const isSaasArea = computed(() => String(route.path || '').startsWith('/saas'));
|
|
const saasActiveCount = ref(0);
|
|
|
|
async function loadSaasNoticeCount() {
|
|
try {
|
|
const all = await fetchAllNotices();
|
|
saasActiveCount.value = all.filter((n) => n.is_active).length;
|
|
} catch {
|
|
/* silencioso */
|
|
}
|
|
}
|
|
|
|
watch(
|
|
isSaasArea,
|
|
(is) => {
|
|
if (is) loadSaasNoticeCount();
|
|
},
|
|
{ immediate: true }
|
|
);
|
|
|
|
/* ----------------------------
|
|
Logout
|
|
----------------------------- */
|
|
async function logout() {
|
|
const tenant = useTenantStore();
|
|
const ent = useEntitlementsStore();
|
|
const tf = useTenantFeaturesStore();
|
|
|
|
try {
|
|
await supabase.auth.signOut();
|
|
} finally {
|
|
tenant.reset();
|
|
ent.invalidate();
|
|
tf.invalidate();
|
|
|
|
sessionStorage.clear();
|
|
localStorage.clear();
|
|
|
|
router.replace('/auth/login');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* ✅ Bootstrap entitlements (resolve "menu não alterna" sem depender do guard)
|
|
* - se tem tenant ativo => carrega tenant entitlements
|
|
* - senão => carrega user entitlements
|
|
*/
|
|
async function bootstrapEntitlements() {
|
|
try {
|
|
const uid = sessionUid.value || (await getMyUserId());
|
|
const tid = tenantId.value;
|
|
|
|
if (tid) {
|
|
await entitlementsStore.loadForTenant(tid, { force: false, maxAgeMs: 60_000 });
|
|
} else if (uid) {
|
|
await entitlementsStore.loadForUser(uid, { force: false, maxAgeMs: 60_000 });
|
|
}
|
|
} catch (e) {
|
|
console.warn('[Topbar][entitlements bootstrap] falhou:', e?.message || e);
|
|
}
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await initUserSettings();
|
|
await loadAndApplyUserSettings();
|
|
await loadSessionIdentity();
|
|
await bootstrapEntitlements();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<header class="rail-topbar">
|
|
<!-- Esquerda -->
|
|
<div class="rail-topbar__left">
|
|
<!-- Hamburguer: só aparece em ≤ xl (1280px) no Rail -->
|
|
<button class="layout-menu-button rail-topbar__btn rail-topbar__hamburger" @click="toggleMenu">
|
|
<i class="pi pi-bars"></i>
|
|
</button>
|
|
|
|
<router-link to="/" class="layout-topbar-logo ml-3">
|
|
<span>Agência PSI</span>
|
|
</router-link>
|
|
|
|
<!-- Indicador de avisos globais ativos — SaaS only -->
|
|
<router-link v-if="isSaasArea && saasActiveCount > 0" to="/saas/global-notices" class="topbar-notice-chip ml-3" :title="`${saasActiveCount} aviso(s) global(is) ativo(s)`">
|
|
<span class="topbar-notice-chip__dot" />
|
|
<i class="pi pi-megaphone topbar-notice-chip__icon" />
|
|
<span class="topbar-notice-chip__label"> {{ saasActiveCount }} aviso{{ saasActiveCount !== 1 ? 's' : '' }} ativo{{ saasActiveCount !== 1 ? 's' : '' }} </span>
|
|
</router-link>
|
|
|
|
<!-- Pills: visíveis apenas em > xl (1280px) -->
|
|
<div class="topbar-ctx-row ml-2">
|
|
<span v-for="(it, idx) in ctxItems" :key="`${it.k}-${idx}`" class="topbar-ctx-pill" :title="`${it.k}: ${it.v}`">
|
|
<b class="topbar-ctx-k">{{ it.k }}:</b>
|
|
<span class="topbar-ctx-v">{{ it.v }}</span>
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Botão Tenant/UID: visível apenas em ≤ xl (1280px) -->
|
|
<button type="button" class="rail-topbar__btn topbar-ctx-btn ml-2" title="Tenant / UID" @click="openCtxMenu">
|
|
<i class="pi pi-id-card" />
|
|
<span class="topbar-ctx-btn__label">Tenant / UID</span>
|
|
</button>
|
|
<Menu ref="ctxMenu" :model="ctxMenuModel" popup appendTo="body" :baseZIndex="3000" />
|
|
</div>
|
|
|
|
<!-- Busca global (Ctrl+K) -->
|
|
<div class="rail-topbar__search">
|
|
<GlobalSearch />
|
|
</div>
|
|
|
|
<!-- Ações -->
|
|
<div class="rail-topbar__actions">
|
|
<!-- Plan Dev Button -->
|
|
<Button v-if="showPlanDevMenu" ref="planBtn" outlined :loading="planMenuLoading || trocandoPlano" :disabled="planMenuLoading || trocandoPlano" @click="openPlanMenu" class="rail-topbar__btn">
|
|
<i class="pi pi-sliders-h" />
|
|
</Button>
|
|
|
|
<Menu ref="planMenu" :model="planMenuModel" popup appendTo="body" :baseZIndex="3000" />
|
|
|
|
<!-- Notificações -->
|
|
<div class="relative">
|
|
<button type="button" class="rail-topbar__btn" title="Notificações" @click="notificationStore.drawerOpen = true">
|
|
<i class="pi pi-bell" />
|
|
<span v-if="notificationStore.unreadCount > 0" class="rail-topbar__notification-badge">
|
|
{{ notificationStore.unreadCount > 99 ? '99+' : notificationStore.unreadCount }}
|
|
</span>
|
|
</button>
|
|
<NotificationDrawer />
|
|
</div>
|
|
|
|
<!-- Ajuda -->
|
|
<button type="button" class="rail-topbar__btn" :class="{ 'rail-topbar__btn--active': ajudaDrawerOpen }" :title="ajudaDrawerOpen ? 'Fechar ajuda' : 'Ajuda'" @click="toggleAjuda">
|
|
<i class="pi pi-question-circle" />
|
|
</button>
|
|
|
|
<!-- Dark mode -->
|
|
<button type="button" class="rail-topbar__btn" :title="isDarkTheme ? 'Modo claro' : 'Modo escuro'" @click="toggleDarkAndPersistSilently">
|
|
<i :class="['pi', { 'pi-moon': isDarkTheme, 'pi-sun': !isDarkTheme }]"></i>
|
|
</button>
|
|
|
|
</div>
|
|
</header>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.rail-topbar {
|
|
position: fixed;
|
|
top: var(--notice-banner-height, 0px);
|
|
left: 0;
|
|
right: 0;
|
|
height: 56px;
|
|
flex-shrink: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
padding: 0 1.25rem;
|
|
border-bottom: 1px solid var(--surface-border);
|
|
background: var(--surface-card);
|
|
z-index: 100;
|
|
}
|
|
|
|
.rail-topbar__left {
|
|
display: flex;
|
|
align-items: center;
|
|
min-width: 0;
|
|
}
|
|
|
|
/* Hamburguer: visível apenas em ≤ xl (1280px)
|
|
!important necessário para sobrescrever CSS do tema (.layout-menu-button) */
|
|
.rail-topbar__hamburger {
|
|
display: none !important;
|
|
}
|
|
@media (width <= theme(--breakpoint-xl, 1280px)) {
|
|
.rail-topbar__hamburger {
|
|
display: grid !important;
|
|
}
|
|
}
|
|
|
|
.rail-topbar__actions {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
}
|
|
|
|
/* Busca global — centralizada entre left e actions */
|
|
.rail-topbar__search {
|
|
display: flex;
|
|
align-items: center;
|
|
flex: 1 1 auto;
|
|
justify-content: center;
|
|
min-width: 0;
|
|
padding: 0 1rem;
|
|
max-width: 520px;
|
|
margin: 0 auto;
|
|
}
|
|
@media (max-width: 640px) {
|
|
.rail-topbar__search {
|
|
padding: 0 0.5rem;
|
|
}
|
|
}
|
|
@media (max-width: 480px) {
|
|
.rail-topbar__search {
|
|
padding: 0 0.25rem;
|
|
flex: 0 0 auto;
|
|
}
|
|
}
|
|
|
|
.rail-topbar__btn {
|
|
width: 2.25rem;
|
|
height: 2.25rem;
|
|
border-radius: 50%;
|
|
border: none;
|
|
background: transparent;
|
|
color: var(--text-color-secondary);
|
|
display: grid;
|
|
place-items: center;
|
|
cursor: pointer;
|
|
font-size: 1rem;
|
|
transition:
|
|
background 0.15s,
|
|
color 0.15s;
|
|
}
|
|
.rail-topbar__btn:hover {
|
|
background: var(--surface-ground);
|
|
color: var(--text-color);
|
|
}
|
|
.rail-topbar__btn--highlight {
|
|
color: var(--primary-color);
|
|
}
|
|
.rail-topbar__btn--active {
|
|
background: var(--surface-ground);
|
|
color: var(--primary-color);
|
|
}
|
|
.config-panel {
|
|
z-index: 200;
|
|
}
|
|
|
|
/* ── Chip de avisos ativos (SaaS) ────────────────────────── */
|
|
.topbar-notice-chip {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
padding: 0.28rem 0.75rem 0.28rem 0.55rem;
|
|
border-radius: 999px;
|
|
background: #f59e0b;
|
|
color: #fff;
|
|
font-size: 0.78rem;
|
|
font-weight: 700;
|
|
text-decoration: none;
|
|
white-space: nowrap;
|
|
flex-shrink: 0;
|
|
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.55);
|
|
animation: topbar-notice-pulse 2s ease-in-out infinite;
|
|
transition: filter 0.15s;
|
|
}
|
|
.topbar-notice-chip:hover {
|
|
filter: brightness(1.1);
|
|
}
|
|
.topbar-notice-chip__dot {
|
|
width: 7px;
|
|
height: 7px;
|
|
border-radius: 50%;
|
|
background: #fff;
|
|
flex-shrink: 0;
|
|
animation: topbar-dot-blink 1.4s ease-in-out infinite;
|
|
}
|
|
.topbar-notice-chip__icon {
|
|
font-size: 0.8rem;
|
|
}
|
|
@keyframes topbar-notice-pulse {
|
|
0%,
|
|
100% {
|
|
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.55);
|
|
}
|
|
50% {
|
|
box-shadow: 0 0 0 6px rgba(245, 158, 11, 0);
|
|
}
|
|
}
|
|
@keyframes topbar-dot-blink {
|
|
0%,
|
|
100% {
|
|
opacity: 1;
|
|
}
|
|
50% {
|
|
opacity: 0.35;
|
|
}
|
|
}
|
|
/* Mobile: oculta o texto, mantém ícone + dot */
|
|
@media (max-width: 600px) {
|
|
.topbar-notice-chip__label {
|
|
display: none;
|
|
}
|
|
.topbar-notice-chip {
|
|
padding: 0.28rem 0.5rem;
|
|
}
|
|
}
|
|
|
|
/* Badge de notificações */
|
|
.rail-topbar__notification-badge {
|
|
position: absolute;
|
|
top: 0;
|
|
right: 0;
|
|
min-width: 1rem;
|
|
height: 1rem;
|
|
padding: 0 0.25rem;
|
|
border-radius: 999px;
|
|
background: #ef4444;
|
|
color: #fff;
|
|
font-size: 0.62rem;
|
|
font-weight: 700;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
line-height: 1;
|
|
pointer-events: none;
|
|
transform: translate(25%, -25%);
|
|
}
|
|
.topbar-ctx-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.45rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.topbar-ctx-pill {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.35rem;
|
|
padding: 0.25rem 0.45rem;
|
|
border-radius: 999px;
|
|
border: 1px solid var(--surface-border);
|
|
background: var(--surface-card);
|
|
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02);
|
|
}
|
|
|
|
/* Botão Tenant/UID: só em ≤ xl (1280px) */
|
|
.topbar-ctx-btn {
|
|
display: none !important;
|
|
width: auto !important;
|
|
border-radius: 999px !important;
|
|
padding: 0 0.65rem !important;
|
|
gap: 0.35rem;
|
|
font-size: 0.8rem;
|
|
border: 1px solid var(--surface-border) !important;
|
|
}
|
|
.topbar-ctx-btn__label {
|
|
font-size: 0.8rem;
|
|
color: var(--text-color-secondary);
|
|
}
|
|
@media (width <= theme(--breakpoint-xl, 1280px)) {
|
|
.topbar-ctx-row {
|
|
display: none !important;
|
|
}
|
|
.topbar-ctx-btn {
|
|
display: inline-flex !important;
|
|
}
|
|
}
|
|
</style>
|