+ Conflito detectado: Já possui um evento neste horário!
+
+
+
+
+
+
+
+
Repetição:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Lembrete:
+
+
+
+
+
+
+
+
+
+
+
Atenção :
+
+
Este é um paciente recorrente, com horários já pré-estabelecidos na sua agenda.
+
Se continuar o sistema adicionará uma sessão extra.
+
Se deseja alterar os horários pré-estabelecidos, clique aqui
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/features/agenda/components/AgendaCalendar.vue b/src/features/agenda/components/AgendaCalendar.vue
new file mode 100644
index 0000000..abce031
--- /dev/null
+++ b/src/features/agenda/components/AgendaCalendar.vue
@@ -0,0 +1,10 @@
+
+
+
+
+ AgendaCalendar (placeholder)
+
+
\ No newline at end of file
diff --git a/src/features/agenda/components/AgendaClinicCalendar.vue b/src/features/agenda/components/AgendaClinicCalendar.vue
new file mode 100644
index 0000000..a18fd72
--- /dev/null
+++ b/src/features/agenda/components/AgendaClinicCalendar.vue
@@ -0,0 +1,100 @@
+
+
+
+
+
+
+ Carregando agenda da clínica…
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/features/agenda/components/AgendaClinicMosaic.vue b/src/features/agenda/components/AgendaClinicMosaic.vue
new file mode 100644
index 0000000..3a17a8c
--- /dev/null
+++ b/src/features/agenda/components/AgendaClinicMosaic.vue
@@ -0,0 +1,192 @@
+
+
+
+
+
+ Carregando agenda da clínica…
+
+
+
+
+
+
+
+
+
{{ p.title }}
+
Visão diária operacional
+
+
+ {{ mode === 'full_24h' ? '24h' : 'Horário' }}
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/features/agenda/components/AgendaEventDialog.vue b/src/features/agenda/components/AgendaEventDialog.vue
new file mode 100644
index 0000000..d9b373a
--- /dev/null
+++ b/src/features/agenda/components/AgendaEventDialog.vue
@@ -0,0 +1,243 @@
+
+
+
+
+
\ No newline at end of file
diff --git a/src/features/agenda/components/AgendaRightPanel.vue b/src/features/agenda/components/AgendaRightPanel.vue
new file mode 100644
index 0000000..ff011ee
--- /dev/null
+++ b/src/features/agenda/components/AgendaRightPanel.vue
@@ -0,0 +1,13 @@
+
+
+
+
+ AgendaRightPanel (placeholder)
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/features/agenda/components/AgendaToolbar.vue b/src/features/agenda/components/AgendaToolbar.vue
new file mode 100644
index 0000000..682ac34
--- /dev/null
+++ b/src/features/agenda/components/AgendaToolbar.vue
@@ -0,0 +1,115 @@
+
+
+
+
+
+
+
+
+
{{ title }}
+
Operação do dia com visão e ação.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Modo: {{ modeLabel }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/features/agenda/components/ConflictBanner.vue b/src/features/agenda/components/ConflictBanner.vue
new file mode 100644
index 0000000..e69de29
diff --git a/src/features/agenda/components/PreviewTimeline.vue b/src/features/agenda/components/PreviewTimeline.vue
new file mode 100644
index 0000000..e69de29
diff --git a/src/features/agenda/components/cards/AgendaNextSessionsCardList.vue b/src/features/agenda/components/cards/AgendaNextSessionsCardList.vue
new file mode 100644
index 0000000..677e4d7
--- /dev/null
+++ b/src/features/agenda/components/cards/AgendaNextSessionsCardList.vue
@@ -0,0 +1,12 @@
+
+
+
+
+ AgendaNextSessionsCardList (placeholder)
+
+
\ No newline at end of file
diff --git a/src/features/agenda/components/cards/AgendaPulseCardGrid.vue b/src/features/agenda/components/cards/AgendaPulseCardGrid.vue
new file mode 100644
index 0000000..b3ac6f0
--- /dev/null
+++ b/src/features/agenda/components/cards/AgendaPulseCardGrid.vue
@@ -0,0 +1,12 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/navigation/index.js b/src/navigation/index.js
index fde1bad..6cce021 100644
--- a/src/navigation/index.js
+++ b/src/navigation/index.js
@@ -1,54 +1,96 @@
// src/navigation/index.js
+
+// ======================================================
+// 📦 Importação dos menus base por área
+// ======================================================
+
import adminMenu from './menus/admin.menu'
import therapistMenu from './menus/therapist.menu'
-import patientMenu from './menus/patient.menu'
+import portalMenu from './menus/portal.menu'
import sakaiDemoMenu from './menus/sakai.demo.menu'
import saasMenu from './menus/saas.menu'
import { useSaasHealthStore } from '@/stores/saasHealthStore'
+import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
+
+// ======================================================
+// 🎭 Mapeamento de role → menu base
+// ======================================================
const MENUS = {
- admin: adminMenu,
+ // ✅ role real do tenant
+ clinic_admin: adminMenu,
therapist: therapistMenu,
- patient: patientMenu
+ patient: portalMenu,
+
+ // ✅ compatibilidade profiles.role
+ admin: adminMenu,
+
+ // ✅ legado
+ tenant_admin: adminMenu
}
-// aceita export de menu como ARRAY ou como FUNÇÃO (ctx) => []
+// ======================================================
+// 🧠 Função utilitária
+// Permite que o menu seja:
+// - Array direto
+// - ou função (ctx) => Array
+// ======================================================
+
function resolveMenu (builder, ctx) {
if (!builder) return []
return typeof builder === 'function' ? builder(ctx) : builder
}
-/**
- * role: vem do seu contexto (admin/therapist/patient)
- * sessionCtx: objeto que tenha { isSaasAdmin: boolean } (ex.: authStore, sessionStore, etc.)
- */
-export function getMenuByRole (role, sessionCtx) {
- const base = resolveMenu(MENUS[role], sessionCtx)
+// ======================================================
+// 🎯 getMenuByRole
+// ======================================================
- // ✅ badge dinâmica do Health (contador vem do store)
- // ⚠️ não faz fetch aqui: o AppMenu carrega o store.
+export function getMenuByRole (role, sessionCtx = {}) {
+ // 🔹 Store de health do SaaS (badge dinâmica)
+ // ⚠️ Não faz fetch aqui. O AppMenu carrega o store.
const saasHealthStore = useSaasHealthStore()
const mismatchCount = saasHealthStore?.mismatchCount || 0
- // ✅ menu SaaS entra como overlay, não depende de role
- // passa opts com mismatchCount (saas.menu.js vai usar pra badge)
+ // 🔹 Store de módulos por tenant (tenant_features)
+ // ⚠️ Não faz fetch aqui. O guard/app deve carregar. Aqui só lemos cache.
+ const tenantFeaturesStore = useTenantFeaturesStore()
+
+ // 🔹 SaaS overlay aparece somente para SaaS master
+ const isSaas = sessionCtx?.isSaasAdmin === true
+
+ // ctx que será passado pros menu builders
+ const ctx = {
+ ...sessionCtx,
+ mismatchCount,
+ tenantFeaturesStore,
+ tenantFeatureEnabled: (key) => {
+ try { return !!tenantFeaturesStore?.isEnabled?.(key) } catch { return false }
+ }
+ }
+
+ // 🔹 Menu base da role
+ const base = resolveMenu(MENUS[role], ctx)
+
+ // 🔹 Resolve menu SaaS (array ou função)
const saas = typeof saasMenu === 'function'
- ? saasMenu(sessionCtx, { mismatchCount })
+ ? saasMenu(ctx, { mismatchCount })
: saasMenu
- // ✅ mantém demos disponíveis para admin em DEV (não polui prod)
- if (role === 'admin' && import.meta.env.DEV) {
- return [
- ...base,
- ...(saas.length ? [{ separator: true }, ...saas] : []),
- { separator: true },
- ...sakaiDemoMenu
- ]
- }
+ // ======================================================
+ // 🚀 Menu final
+ // - base sempre
+ // - overlay SaaS só para SaaS master
+ // - Demo Sakai só para SaaS master em DEV
+ // ======================================================
return [
...base,
- ...(saas.length ? [{ separator: true }, ...saas] : [])
+
+ ...(isSaas && saas.length ? [{ separator: true }, ...saas] : []),
+
+ ...(isSaas && import.meta.env.DEV
+ ? [{ separator: true }, ...sakaiDemoMenu]
+ : [])
]
-}
+}
\ No newline at end of file
diff --git a/src/navigation/menus/admin.menu.js b/src/navigation/menus/admin.menu.js
index dce0f6a..2fb0667 100644
--- a/src/navigation/menus/admin.menu.js
+++ b/src/navigation/menus/admin.menu.js
@@ -1,76 +1,111 @@
-export default [
- {
- label: 'Admin',
+// src/navigation/menus/admin.menu.js
+
+export default function adminMenu (ctx = {}) {
+ const patientsOn = !!ctx?.tenantFeatureEnabled?.('patients')
+
+ const menu = [
+ // =====================================================
+ // 📊 OPERAÇÃO
+ // =====================================================
+ {
+ label: 'Operação',
+ items: [
+ {
+ label: 'Dashboard',
+ icon: 'pi pi-fw pi-home',
+ to: '/admin'
+ },
+ {
+ label: 'Agenda',
+ icon: 'pi pi-fw pi-calendar',
+ to: '/admin/agenda',
+ feature: 'agenda.view'
+ },
+ {
+ label: 'Agenda da Clínica',
+ icon: 'pi pi-fw pi-sitemap',
+ to: '/admin/agenda/clinica',
+ feature: 'agenda.view'
+ }
+ ]
+ }
+ ]
+
+ // =====================================================
+ // 👥 PACIENTES (somente se módulo ativo)
+ // =====================================================
+ if (patientsOn) {
+ menu.push({
+ label: 'Pacientes',
+ items: [
+ {
+ label: 'Lista de Pacientes',
+ icon: 'pi pi-fw pi-users',
+ to: '/admin/pacientes'
+ },
+ {
+ label: 'Grupos',
+ icon: 'pi pi-fw pi-users',
+ to: '/admin/pacientes/grupos'
+ },
+ {
+ label: 'Tags',
+ icon: 'pi pi-fw pi-tags',
+ to: '/admin/pacientes/tags'
+ },
+ {
+ label: 'Link Externo',
+ icon: 'pi pi-fw pi-link',
+ to: '/admin/pacientes/link-externo'
+ }
+ ]
+ })
+ }
+
+ // =====================================================
+ // ⚙️ GESTÃO DA CLÍNICA
+ // =====================================================
+ menu.push({
+ label: 'Gestão',
items: [
{
- label: 'Dashboard',
- icon: 'pi pi-fw pi-home',
- to: '/admin'
+ label: 'Profissionais',
+ icon: 'pi pi-fw pi-id-card',
+ to: '/admin/clinic/professionals'
},
{
- label: 'Clínicas',
- icon: 'pi pi-fw pi-building',
- to: '/admin/clinics'
- },
- {
- label: 'Usuários',
- icon: 'pi pi-fw pi-users',
- to: '/admin/users'
+ label: 'Módulos da Clínica',
+ icon: 'pi pi-fw pi-sliders-h',
+ to: '/admin/clinic/features'
},
{
label: 'Assinatura',
icon: 'pi pi-fw pi-credit-card',
to: '/admin/billing'
- },
+ }
+ ]
+ })
- // 🔒 MÓDULO PRO (exemplo)
- {
- label: 'Agendamento Online (PRO)',
- icon: 'pi pi-fw pi-calendar',
- to: '/admin/online-scheduling',
- feature: 'online_scheduling.manage',
- proBadge: true
- },
-
- // ✅ ajustado para bater com sua rota "configuracoes"
+ // =====================================================
+ // 🔒 SISTEMA
+ // =====================================================
+ menu.push({
+ label: 'Sistema',
+ items: [
{
label: 'Segurança',
icon: 'pi pi-fw pi-shield',
- to: '/admin/configuracoes/seguranca'
+ to: '/admin/settings/security'
+ },
+ {
+ label: 'Agendamento Online (PRO)',
+ icon: 'pi pi-fw pi-calendar-plus',
+ to: '/admin/online-scheduling',
+ feature: 'online_scheduling.manage',
+ proBadge: true
}
]
- },
- {
- label: 'Pacientes',
- items: [
- {
- label: 'Meus Pacientes',
- icon: 'pi pi-list',
- to: '/admin/pacientes',
- quickCreate: true,
- quickCreateFullTo: '/admin/pacientes/novo',
- quickCreateEntity: 'patient'
- },
- {
- label: 'Grupos de pacientes',
- icon: 'pi pi-fw pi-users',
- to: '/admin/pacientes/grupos'
- },
- {
- label: 'Tags',
- icon: 'pi pi-tags',
- to: '/admin/pacientes/tags'
- },
- {
- label: 'Link externo (Cadastro)',
- icon: 'pi pi-link',
- to: '/admin/pacientes/link-externo'
- },
- {
- label: 'Cadastros Recebidos',
- icon: 'pi pi-inbox',
- to: '/admin/pacientes/cadastro/recebidos'
- }
- ]
- }
-]
+ })
+
+ return menu
+}
\ No newline at end of file
diff --git a/src/navigation/menus/patient.menu.js b/src/navigation/menus/portal.menu.js
similarity index 69%
rename from src/navigation/menus/patient.menu.js
rename to src/navigation/menus/portal.menu.js
index 974984c..d6df8a4 100644
--- a/src/navigation/menus/patient.menu.js
+++ b/src/navigation/menus/portal.menu.js
@@ -5,20 +5,12 @@ export default [
// ======================
// ✅ Básico (sempre)
// ======================
- { label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/patient' },
-
- { label: 'Consultas', icon: 'pi pi-fw pi-calendar-plus', to: '/patient/appointments' },
-
- { label: 'Meu Perfil', icon: 'pi pi-fw pi-user', to: '/patient/profile' },
+ { label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/portal' },
+ { label: 'Minha Agenda', icon: 'pi pi-fw pi-calendar-plus', to: '/portal/agenda' },
+ { label: 'Agendar Sessão', icon: 'pi pi-fw pi-user', to: '/portal/agenda/new' },
+ // ✅ Conta é global, não do portal
+ { label: 'My Account', icon: 'pi pi-fw pi-user', to: '/account/profile' }
- {
- label: 'Agendamento online',
- icon: 'pi pi-fw pi-globe',
- to: '/patient/online-scheduling',
- feature: 'online_scheduling.manage',
- proBadge: true
- },
-
// =====================================================
// 🔒 PRO (exemplos futuros no portal do paciente)
// =====================================================
@@ -32,7 +24,7 @@ export default [
// {
// label: 'Agendar online',
// icon: 'pi pi-fw pi-globe',
- // to: '/patient/online-scheduling',
+ // to: '/portal/online-scheduling',
// feature: 'online_scheduling.public',
// proBadge: true
// },
@@ -41,7 +33,7 @@ export default [
// {
// label: 'Documentos',
// icon: 'pi pi-fw pi-file',
- // to: '/patient/documents',
+ // to: '/portal/documents',
// feature: 'patient_documents',
// proBadge: true
// },
@@ -50,7 +42,7 @@ export default [
// {
// label: 'Sala de atendimento',
// icon: 'pi pi-fw pi-video',
- // to: '/patient/telehealth',
+ // to: '/portal/telehealth',
// feature: 'telehealth',
// proBadge: true
// }
diff --git a/src/navigation/menus/saas.menu.js b/src/navigation/menus/saas.menu.js
index 17b95d9..40a54fb 100644
--- a/src/navigation/menus/saas.menu.js
+++ b/src/navigation/menus/saas.menu.js
@@ -1,10 +1,15 @@
// src/navigation/menus/saas.menu.js
-export default function saasMenu (authStore, opts = {}) {
- if (!authStore?.isSaasAdmin) return []
+export default function saasMenu (sessionCtx, opts = {}) {
+ if (!sessionCtx?.isSaasAdmin) return []
const mismatchCount = Number(opts?.mismatchCount || 0)
+ // ✅ helper p/ evitar repetir spread + manter comentários intactos
+ const mismatchBadge = mismatchCount > 0
+ ? { badge: String(mismatchCount), badgeClass: 'p-badge p-badge-danger' }
+ : {}
+
return [
{
label: 'SaaS',
@@ -16,11 +21,11 @@ export default function saasMenu (authStore, opts = {}) {
{
label: 'Planos',
icon: 'pi pi-star',
- path: '/plans', // ✅ vira /saas/plans pelo parentPath
+ path: '/saas/plans', // ✅ absoluto (mais confiável p/ active/expand)
items: [
{ label: 'Listagem de Planos', icon: 'pi pi-list', to: '/saas/plans' },
- // ✅ NOVO: vitrine pública (pricing page)
+ // ✅ vitrine pública (pricing page)
{ label: 'Vitrine Pública', icon: 'pi pi-megaphone', to: '/saas/plans-public' },
{ label: 'Recursos', icon: 'pi pi-bolt', to: '/saas/features' },
@@ -31,7 +36,7 @@ export default function saasMenu (authStore, opts = {}) {
{
label: 'Assinaturas',
icon: 'pi pi-credit-card',
- path: '/subscriptions', // ✅ vira /saas/subscriptions
+ path: '/saas/subscriptions', // ✅ absoluto
items: [
{ label: 'Listagem de Assinaturas', icon: 'pi pi-list', to: '/saas/subscriptions' },
{ label: 'Histórico', icon: 'pi pi-history', to: '/saas/subscription-events' },
@@ -39,20 +44,21 @@ export default function saasMenu (authStore, opts = {}) {
label: 'Saúde das Assinaturas',
icon: 'pi pi-shield',
to: '/saas/subscription-health',
- ...(mismatchCount > 0
- ? { badge: String(mismatchCount), badgeClass: 'p-badge p-badge-danger' }
+ ...(mismatchBadge
+ ? mismatchBadge
: {})
}
]
},
- {
- label: 'Intenções de Assinatura',
- icon: 'pi pi-inbox',
- to: '/saas/subscription-intents'
- },
+
+ {
+ label: 'Intenções de Assinatura',
+ icon: 'pi pi-inbox',
+ to: '/saas/subscription-intents'
+ },
{ label: 'Clínicas (Tenants)', icon: 'pi pi-users', to: '/saas/tenants' }
]
}
]
-}
+}
\ No newline at end of file
diff --git a/src/navigation/menus/therapist.menu.js b/src/navigation/menus/therapist.menu.js
index 6e57b94..681a197 100644
--- a/src/navigation/menus/therapist.menu.js
+++ b/src/navigation/menus/therapist.menu.js
@@ -1,20 +1,47 @@
+// src/navigation/menus/therapist.menu.js
+
export default [
{
- label: 'Terapeuta',
+ label: 'Therapist',
items: [
+ // ======================================================
+ // 📊 DASHBOARD
+ // ======================================================
{ label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/therapist' },
+
+ // ======================================================
+ // 📅 AGENDA
+ // ======================================================
{ label: 'Agenda', icon: 'pi pi-fw pi-calendar', to: '/therapist/agenda' },
- // ✅ PRO
+ // ======================================================
+ // 👥 PATIENTS
+ // ======================================================
+ { label: 'Meus pacientes', icon: 'pi pi-list', to: '/therapist/patients' },
+
+ { label: 'Grupo de pacientes', icon: 'pi pi-fw pi-users', to: '/therapist/patients/grupos' },
+
+ { label: 'Tags', icon: 'pi pi-tags', to: '/therapist/patients/tags' },
+
+ { label: 'Meu link de cadastro', icon: 'pi pi-link', to: '/therapist/patients/link-externo' },
+
+ { label: 'Cadastros recebidos', icon: 'pi pi-inbox', to: '/therapist/patients/cadastro/recebidos' },
+
+ // ======================================================
+ // 🔒 PRO — Online Scheduling
+ // ======================================================
{
- label: 'Agendamento online',
+ label: 'Online Scheduling',
icon: 'pi pi-fw pi-globe',
to: '/therapist/online-scheduling',
feature: 'online_scheduling.manage',
proBadge: true
},
- { label: 'Pacientes', icon: 'pi pi-fw pi-id-card', to: '/therapist/patients' }
+ // ======================================================
+ // 👤 ACCOUNT
+ // ======================================================
+ { label: 'Meu Perfil', icon: 'pi pi-fw pi-user', to: '/account/profile' }
]
}
-]
+]
\ No newline at end of file
diff --git a/src/router/guards.js b/src/router/guards.js
index 9790796..66cf1a7 100644
--- a/src/router/guards.js
+++ b/src/router/guards.js
@@ -1,3 +1,4 @@
+// src/router/guard.js
// ⚠️ Guard depende de sessão estável.
// Nunca disparar refresh concorrente durante navegação protegida.
// Ver comentário em session.js sobre race condition.
@@ -5,6 +6,7 @@
import { supabase } from '@/lib/supabase/client'
import { useTenantStore } from '@/stores/tenantStore'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
+import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
import { buildUpgradeUrl } from '@/utils/upgradeContext'
import { sessionUser, sessionReady, sessionRefreshing, initSession } from '@/app/session'
@@ -16,11 +18,33 @@ let sessionUidCache = null
let saasAdminCacheUid = null
let saasAdminCacheIsAdmin = null
+// -----------------------------------------
+// Pending invite (Modelo B) — retomada pós-login
+// -----------------------------------------
+const PENDING_INVITE_TOKEN_KEY = 'pending_invite_token_v1'
+
+function readPendingInviteToken () {
+ try { return sessionStorage.getItem(PENDING_INVITE_TOKEN_KEY) } catch (_) { return null }
+}
+
+function clearPendingInviteToken () {
+ try { sessionStorage.removeItem(PENDING_INVITE_TOKEN_KEY) } catch (_) {}
+}
+
+function isUuid (v) {
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(v || ''))
+}
+
function roleToPath (role) {
- if (role === 'tenant_admin') return '/admin'
+ // ✅ clínica: aceita nomes canônicos e legado
+ if (role === 'clinic_admin' || role === 'tenant_admin' || role === 'admin') return '/admin'
+
if (role === 'therapist') return '/therapist'
- if (role === 'patient') return '/patient'
+ if (role === 'patient') return '/portal'
+
+ // ✅ saas master
if (role === 'saas_admin') return '/saas'
+
return '/'
}
@@ -103,12 +127,28 @@ export function applyGuards (router) {
console.time(tlabel)
try {
- // públicos
- if (to.meta?.public) { console.timeEnd(tlabel); return true }
- if (to.path.startsWith('/auth')) { console.timeEnd(tlabel); return true }
+ // ==========================================
+ // ✅ AUTH SEMPRE LIBERADO (blindagem total)
+ // (ordem importa: /auth antes de meta.public)
+ // ==========================================
+ if (to.path.startsWith('/auth')) {
+ console.timeEnd(tlabel)
+ return true
+ }
+
+ // ==========================================
+ // ✅ Rotas públicas
+ // ==========================================
+ if (to.meta?.public) {
+ console.timeEnd(tlabel)
+ return true
+ }
// se rota não exige auth, libera
- if (!to.meta?.requiresAuth) { console.timeEnd(tlabel); return true }
+ if (!to.meta?.requiresAuth) {
+ console.timeEnd(tlabel)
+ return true
+ }
// não decide nada no meio do refresh do session.js
console.timeLog(tlabel, 'waitSessionIfRefreshing')
@@ -122,6 +162,29 @@ export function applyGuards (router) {
return { path: '/auth/login' }
}
+ // ==========================================
+ // ✅ Pending invite (Modelo B): se o usuário logou e tem token pendente,
+ // redireciona para /accept-invite antes de qualquer load pesado.
+ // ==========================================
+ const pendingInviteToken = readPendingInviteToken()
+
+ // Se tiver lixo no storage, limpa para não “travar” o app.
+ if (pendingInviteToken && !isUuid(pendingInviteToken)) {
+ clearPendingInviteToken()
+ }
+
+ // Evita loop/efeito colateral:
+ // - não interfere se já está em /accept-invite
+ // (não precisamos checar /auth aqui porque /auth já retornou lá em cima)
+ if (
+ pendingInviteToken &&
+ isUuid(pendingInviteToken) &&
+ !to.path.startsWith('/accept-invite')
+ ) {
+ console.timeEnd(tlabel)
+ return { path: '/accept-invite', query: { token: pendingInviteToken } }
+ }
+
// se uid mudou, invalida caches e stores dependentes
if (sessionUidCache !== uid) {
sessionUidCache = uid
@@ -130,15 +193,27 @@ export function applyGuards (router) {
const ent0 = useEntitlementsStore()
if (typeof ent0.invalidate === 'function') ent0.invalidate()
+
+ const tf0 = useTenantFeaturesStore()
+ if (typeof tf0.invalidate === 'function') tf0.invalidate()
}
- // saas admin (com cache)
+ // ================================
+ // ✅ SAAS MASTER: não depende de tenant
+ // ================================
if (to.meta?.saasAdmin) {
console.timeLog(tlabel, 'isSaasAdmin')
const ok = await isSaasAdmin(uid)
if (!ok) { console.timeEnd(tlabel); return { path: '/pages/access' } }
+
+ console.timeEnd(tlabel)
+ return true
}
+ // ================================
+ // ✅ Abaixo daqui é tudo tenant-app
+ // ================================
+
// carrega tenant + role
const tenant = useTenantStore()
console.timeLog(tlabel, 'tenant.loadSessionAndTenant?')
@@ -181,6 +256,12 @@ export function applyGuards (router) {
return { path: '/pages/access' }
}
+ // se trocar tenant, invalida cache de tenant_features (evita ler feature de tenant errado)
+ const tfSwitch = useTenantFeaturesStore()
+ if (tfSwitch.loadedForTenantId && tfSwitch.loadedForTenantId !== tenantId) {
+ tfSwitch.invalidate()
+ }
+
// entitlements (✅ carrega só quando precisa)
const ent = useEntitlementsStore()
if (shouldLoadEntitlements(ent, tenantId)) {
@@ -188,6 +269,28 @@ export function applyGuards (router) {
await loadEntitlementsSafe(ent, tenantId, true)
}
+ // ================================
+ // ✅ tenant_features (módulos ativáveis por clínica)
+ // meta.tenantFeature = 'patients' | ...
+ // ================================
+ const requiredTenantFeature = to.meta?.tenantFeature
+ if (requiredTenantFeature) {
+ const tf = useTenantFeaturesStore()
+ console.timeLog(tlabel, 'tenantFeatures.fetchForTenant')
+ await tf.fetchForTenant(tenantId, { force: false })
+
+ if (!tf.isEnabled(requiredTenantFeature)) {
+ // evita loop
+ if (to.path === '/admin/clinic/features') { console.timeEnd(tlabel); return true }
+
+ console.timeEnd(tlabel)
+ return {
+ path: '/admin/clinic/features',
+ query: { missing: requiredTenantFeature, redirectTo: to.fullPath }
+ }
+ }
+ }
+
// roles guard (plural)
const allowedRoles = to.meta?.roles
if (Array.isArray(allowedRoles) && allowedRoles.length) {
@@ -208,18 +311,18 @@ export function applyGuards (router) {
return { path: fallback }
}
- // feature guard
+ // feature guard (entitlements/plano → upgrade)
const requiredFeature = to.meta?.feature
- if (requiredFeature && ent?.can && !ent.can(requiredFeature)) {
- if (to.name === 'upgrade') { console.timeEnd(tlabel); return true }
+ if (requiredFeature && ent?.can && !ent.can(requiredFeature)) {
+ if (to.name === 'upgrade') { console.timeEnd(tlabel); return true }
- const url = buildUpgradeUrl({
- missingKeys: [requiredFeature],
- redirectTo: to.fullPath
- })
- console.timeEnd(tlabel)
- return { path: url }
- }
+ const url = buildUpgradeUrl({
+ missingKeys: [requiredFeature],
+ redirectTo: to.fullPath
+ })
+ console.timeEnd(tlabel)
+ return url
+ }
console.timeEnd(tlabel)
return true
@@ -227,7 +330,8 @@ export function applyGuards (router) {
console.error('[guards] erro no beforeEach:', e)
// fallback seguro
- if (to.meta?.public || to.path.startsWith('/auth')) return true
+ if (to.path.startsWith('/auth')) return true
+ if (to.meta?.public) return true
if (to.path === '/pages/access') return true
sessionStorage.setItem('redirect_after_login', to.fullPath)
@@ -243,6 +347,11 @@ export function applyGuards (router) {
sessionUidCache = null
saasAdminCacheUid = null
saasAdminCacheIsAdmin = null
+
+ try {
+ const tf = useTenantFeaturesStore()
+ if (typeof tf.invalidate === 'function') tf.invalidate()
+ } catch {}
})
}
-}
+}
\ No newline at end of file
diff --git a/src/router/index.js b/src/router/index.js
index 18920ce..5920534 100644
--- a/src/router/index.js
+++ b/src/router/index.js
@@ -1,111 +1,120 @@
-import {
- createRouter,
- createWebHistory,
- isNavigationFailure,
- NavigationFailureType
-} from 'vue-router'
+import { createRouter, createWebHistory, isNavigationFailure, NavigationFailureType } from 'vue-router';
-import publicRoutes from './routes.public'
-import adminRoutes from './routes.admin'
-import therapistRoutes from './routes.therapist'
-import patientRoutes from './routes.patient'
-import miscRoutes from './routes.misc'
-import authRoutes from './routes.auth'
-import configuracoesRoutes from './router.configuracoes'
-import billingRoutes from './routes.billing'
-import saasRoutes from './routes.saas'
-import demoRoutes from './routes.demo'
-import meRoutes from './router.me'
+import configuracoesRoutes from './routes.configs';
+import meRoutes from './routes.account';
+import adminRoutes from './routes.admin';
+import authRoutes from './routes.auth';
+import billingRoutes from './routes.billing';
+import demoRoutes from './routes.demo';
+import miscRoutes from './routes.misc';
+import patientRoutes from './routes.portal';
+import publicRoutes from './routes.public';
+import saasRoutes from './routes.saas';
+import therapistRoutes from './routes.therapist';
+import featuresRoutes from './routes.features'
-import { applyGuards } from './guards'
+import { applyGuards } from './guards';
const routes = [
- ...(Array.isArray(publicRoutes) ? publicRoutes : [publicRoutes]),
- ...(Array.isArray(authRoutes) ? authRoutes : [authRoutes]),
- ...(Array.isArray(miscRoutes) ? miscRoutes : [miscRoutes]),
- ...(Array.isArray(billingRoutes) ? billingRoutes : [billingRoutes]),
- ...(Array.isArray(saasRoutes) ? saasRoutes : [saasRoutes]),
- ...(Array.isArray(meRoutes) ? meRoutes : [meRoutes]),
- ...(Array.isArray(adminRoutes) ? adminRoutes : [adminRoutes]),
- ...(Array.isArray(therapistRoutes) ? therapistRoutes : [therapistRoutes]),
- ...(Array.isArray(patientRoutes) ? patientRoutes : [patientRoutes]),
- ...(Array.isArray(demoRoutes) ? demoRoutes : [demoRoutes]),
- ...(Array.isArray(configuracoesRoutes) ? configuracoesRoutes : [configuracoesRoutes]),
+ ...(Array.isArray(publicRoutes) ? publicRoutes : [publicRoutes]),
+ ...(Array.isArray(authRoutes) ? authRoutes : [authRoutes]),
+ ...(Array.isArray(miscRoutes) ? miscRoutes : [miscRoutes]),
+ ...(Array.isArray(billingRoutes) ? billingRoutes : [billingRoutes]),
+ ...(Array.isArray(saasRoutes) ? saasRoutes : [saasRoutes]),
+ ...(Array.isArray(meRoutes) ? meRoutes : [meRoutes]),
+ ...(Array.isArray(adminRoutes) ? adminRoutes : [adminRoutes]),
+ ...(Array.isArray(therapistRoutes) ? therapistRoutes : [therapistRoutes]),
+ ...(Array.isArray(patientRoutes) ? patientRoutes : [patientRoutes]),
+ ...(Array.isArray(demoRoutes) ? demoRoutes : [demoRoutes]),
+ ...(Array.isArray(configuracoesRoutes) ? configuracoesRoutes : [configuracoesRoutes]),
+ ...(Array.isArray(featuresRoutes) ? featuresRoutes : [featuresRoutes]),
+
+ // ✅ compat: rota antiga /login → /auth/login (evita 404 se algum trecho legado usar /login)
+ {
+ path: '/login',
+ redirect: (to) => ({
+ path: '/auth/login',
+ query: to.query || {}
+ })
+ },
- { path: '/:pathMatch(.*)*', name: 'notfound', component: () => import('@/views/pages/NotFound.vue') }
-]
+ { path: '/:pathMatch(.*)*', name: 'notfound', component: () => import('@/views/pages/NotFound.vue') }
+];
const router = createRouter({
- history: createWebHistory(),
- routes,
- scrollBehavior(to, from, savedPosition) {
- // volta/avançar do navegador mantém posição
- if (savedPosition) return savedPosition
+ history: createWebHistory(),
+ routes,
+ scrollBehavior(to, from, savedPosition) {
+ // volta/avançar do navegador mantém posição
+ if (savedPosition) return savedPosition;
- // qualquer navegação normal NÃO altera o scroll
- return false
- }
-})
+ // qualquer navegação normal NÃO altera o scroll
+ return false;
+ }
+});
/* 🔎 DEBUG: listar todas as rotas registradas */
console.log(
- '[ROUTES]',
- router.getRoutes().map(r => r.path).sort()
-)
+ '[ROUTES]',
+ router
+ .getRoutes()
+ .map((r) => r.path)
+ .sort()
+);
// ===== DEBUG NAV + TRACE (remover depois) =====
-const _push = router.push.bind(router)
+const _push = router.push.bind(router);
router.push = async (loc) => {
- console.log('[router.push]', loc)
- console.trace('[push caller]')
+ console.log('[router.push]', loc);
+ console.trace('[push caller]');
- const res = await _push(loc)
+ const res = await _push(loc);
- if (isNavigationFailure(res, NavigationFailureType.duplicated)) {
- console.warn('[NAV FAIL] duplicated', res)
- } else if (isNavigationFailure(res, NavigationFailureType.cancelled)) {
- console.warn('[NAV FAIL] cancelled', res)
- } else if (isNavigationFailure(res, NavigationFailureType.aborted)) {
- console.warn('[NAV FAIL] aborted', res)
- } else if (isNavigationFailure(res, NavigationFailureType.redirected)) {
- console.warn('[NAV FAIL] redirected', res)
- }
+ if (isNavigationFailure(res, NavigationFailureType.duplicated)) {
+ console.warn('[NAV FAIL] duplicated', res);
+ } else if (isNavigationFailure(res, NavigationFailureType.cancelled)) {
+ console.warn('[NAV FAIL] cancelled', res);
+ } else if (isNavigationFailure(res, NavigationFailureType.aborted)) {
+ console.warn('[NAV FAIL] aborted', res);
+ } else if (isNavigationFailure(res, NavigationFailureType.redirected)) {
+ console.warn('[NAV FAIL] redirected', res);
+ }
- return res
-}
+ return res;
+};
-const _replace = router.replace.bind(router)
+const _replace = router.replace.bind(router);
router.replace = async (loc) => {
- console.log('[router.replace]', loc)
- console.trace('[replace caller]')
+ console.log('[router.replace]', loc);
+ console.trace('[replace caller]');
- const res = await _replace(loc)
+ const res = await _replace(loc);
- if (isNavigationFailure(res, NavigationFailureType.cancelled)) {
- console.warn('[NAV FAIL replace] cancelled', res)
- } else if (isNavigationFailure(res, NavigationFailureType.aborted)) {
- console.warn('[NAV FAIL replace] aborted', res)
- } else if (isNavigationFailure(res, NavigationFailureType.redirected)) {
- console.warn('[NAV FAIL replace] redirected', res)
- }
+ if (isNavigationFailure(res, NavigationFailureType.cancelled)) {
+ console.warn('[NAV FAIL replace] cancelled', res);
+ } else if (isNavigationFailure(res, NavigationFailureType.aborted)) {
+ console.warn('[NAV FAIL replace] aborted', res);
+ } else if (isNavigationFailure(res, NavigationFailureType.redirected)) {
+ console.warn('[NAV FAIL replace] redirected', res);
+ }
- return res
-}
+ return res;
+};
-router.onError((e) => console.error('[router.onError]', e))
+router.onError((e) => console.error('[router.onError]', e));
router.beforeEach((to, from) => {
- console.log('[beforeEach]', from.fullPath, '->', to.fullPath)
- return true
-})
+ console.log('[beforeEach]', from.fullPath, '->', to.fullPath);
+ return true;
+});
router.afterEach((to, from, failure) => {
- if (failure) console.warn('[afterEach failure]', failure)
- else console.log('[afterEach ok]', from.fullPath, '->', to.fullPath)
-})
+ if (failure) console.warn('[afterEach failure]', failure);
+ else console.log('[afterEach ok]', from.fullPath, '->', to.fullPath);
+});
// ===== /DEBUG NAV + TRACE =====
// ✅ mantém seus guards, mas agora a landing tem meta.public
-applyGuards(router)
+applyGuards(router);
-export default router
+export default router;
diff --git a/src/router/router.me.js b/src/router/router.me.js
deleted file mode 100644
index a57f146..0000000
--- a/src/router/router.me.js
+++ /dev/null
@@ -1,32 +0,0 @@
-// src/router/router.me.js
-import AppLayout from '@/layout/AppLayout.vue'
-
-const meRoutes = {
- path: '/me',
- component: AppLayout,
-
- meta: {
- requiresAuth: true,
- roles: ['admin', 'tenant_admin', 'therapist', 'patient']
- },
-
- children: [
- {
- // ✅ quando entrar em /me, manda pro perfil
- path: '',
- redirect: { name: 'MeuPerfil' }
- },
-
- {
- path: 'perfil',
- name: 'MeuPerfil',
- component: () => import('@/views/pages/me/MeuPerfilPage.vue')
- }
-
- // Futuro:
- // { path: 'preferencias', name: 'MePreferencias', component: () => import('@/pages/me/PreferenciasPage.vue') },
- // { path: 'notificacoes', name: 'MeNotificacoes', component: () => import('@/pages/me/NotificacoesPage.vue') },
- ]
-}
-
-export default meRoutes
diff --git a/src/router/routes.account.js b/src/router/routes.account.js
new file mode 100644
index 0000000..cf25c5a
--- /dev/null
+++ b/src/router/routes.account.js
@@ -0,0 +1,23 @@
+import AppLayout from '@/layout/AppLayout.vue'
+
+export default {
+ path: '/account',
+ component: AppLayout,
+ meta: { requiresAuth: true },
+ children: [
+ {
+ path: '',
+ redirect: { name: 'account-profile' }
+ },
+ {
+ path: 'profile',
+ name: 'account-profile',
+ component: () => import('@/views/pages/account/ProfilePage.vue')
+ },
+ {
+ path: 'security',
+ name: 'account-security',
+ component: () => import('@/views/pages/auth/SecurityPage.vue')
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/router/routes.admin.js b/src/router/routes.admin.js
index 55a56aa..1406fcb 100644
--- a/src/router/routes.admin.js
+++ b/src/router/routes.admin.js
@@ -9,78 +9,165 @@ export default {
// 🔐 Tudo aqui dentro exige login
requiresAuth: true,
- // 👤 Perfil de acesso
- role: 'tenant_admin'
+ // 👤 Perfil de acesso (tenant-level)
+ // tenantStore normaliza tenant_admin -> clinic_admin, mas mantemos compatibilidade
+ roles: ['clinic_admin', 'tenant_admin']
},
children: [
- // DASHBOARD
+ // ======================================================
+ // 📊 DASHBOARD
+ // ======================================================
{
path: '',
name: 'admin-dashboard',
component: () => import('@/views/pages/admin/AdminDashboard.vue')
},
- // PACIENTES - LISTA
+ // ======================================================
+ // 🧩 CLÍNICA — MÓDULOS (tenant_features)
+ // ======================================================
+ {
+ path: 'clinic/features',
+ name: 'admin-clinic-features',
+ component: () => import('@/views/pages/admin/clinic/ClinicFeaturesPage.vue'),
+ meta: {
+ // opcional: restringir apenas para admin canônico
+ roles: ['clinic_admin', 'tenant_admin']
+ }
+ },
+ {
+ path: 'clinic/professionals',
+ name: 'admin-clinic-professionals',
+ component: () => import('@/views/pages/admin/clinic/ClinicProfessionalsPage.vue'),
+ meta: {
+ requiresAuth: true,
+ roles: ['clinic_admin', 'tenant_admin']
+ }
+ },
+
+ // ======================================================
+ // 📅 MINHA AGENDA
+ // ======================================================
+
+ // 🔎 Visão geral da agenda
+ {
+ path: 'agenda',
+ name: 'admin-agenda',
+ component: () => import('@/views/pages/admin/agenda/MyAppointmentsPage.vue'),
+ meta: {
+ feature: 'agenda.view'
+ }
+ },
+
+ // ➕ Adicionar novo compromisso
+ {
+ path: 'agenda/adicionar',
+ name: 'admin-agenda-adicionar',
+ component: () => import('@/views/pages/admin/agenda/NewAppointmentPage.vue'),
+ meta: {
+ feature: 'agenda.manage'
+ }
+ },
+
+ {
+ path: 'agenda/clinica',
+ name: 'admin-agenda-clinica',
+ component: () => import('@/features/agenda/pages/AgendaClinicaPage.vue'),
+ meta: {
+ feature: 'agenda.view',
+ roles: ['clinic_admin', 'tenant_admin']
+ }
+ },
+
+ // ======================================================
+ // 👥 PACIENTES (módulo ativável por clínica)
+ // ======================================================
+
+ // 📋 Lista de pacientes
{
path: 'pacientes',
name: 'admin-pacientes',
- component: () => import('@/views/pages/admin/pacientes/PatientsIndexPage.vue')
+ component: () => import('@/features/patients/PatientsListPage.vue'),
+ meta: {
+ // ✅ depende do tenant_features.patients
+ tenantFeature: 'patients'
+ }
},
- // PACIENTES - CADASTRO (NOVO / EDITAR)
+ // ➕ Cadastro de paciente (novo)
{
path: 'pacientes/cadastro',
name: 'admin-pacientes-cadastro',
- component: () => import('@/views/pages/admin/pacientes/cadastro/PatientsCadastroPage.vue')
+ component: () => import('@/features/patients/cadastro/PatientsCadastroPage.vue'),
+ meta: {
+ tenantFeature: 'patients'
+ }
},
+
+ // ✏️ Editar paciente
{
path: 'pacientes/cadastro/:id',
name: 'admin-pacientes-cadastro-edit',
- component: () => import('@/views/pages/admin/pacientes/cadastro/PatientsCadastroPage.vue'),
- props: true
+ component: () => import('@/features/patients/cadastro/PatientsCadastroPage.vue'),
+ props: true,
+ meta: {
+ tenantFeature: 'patients'
+ }
},
- // GRUPOS DE PACIENTES ✅
+ // 👥 Grupos de pacientes
{
path: 'pacientes/grupos',
name: 'admin-pacientes-grupos',
- component: () => import('@/views/pages/admin/pacientes/grupos/GruposPacientesPage.vue')
+ component: () => import('@/features/patients/grupos/GruposPacientesPage.vue'),
+ meta: {
+ tenantFeature: 'patients'
+ }
},
- // TAGS
+ // 🏷️ Tags de pacientes
{
path: 'pacientes/tags',
name: 'admin-pacientes-tags',
- component: () => import('@/views/pages/admin/pacientes/tags/TagsPage.vue')
+ component: () => import('@/features/patients/tags/TagsPage.vue'),
+ meta: {
+ tenantFeature: 'patients'
+ }
},
- // LINK EXTERNO
+ // 🔗 Link externo para cadastro
{
path: 'pacientes/link-externo',
- name: 'admin.pacientes.linkexterno',
- component: () => import('@/views/pages/admin/pacientes/cadastro/PatientsExternalLinkPage.vue')
+ name: 'admin-pacientes-link-externo',
+ component: () => import('@/features/patients/cadastro/PatientsExternalLinkPage.vue'),
+ meta: {
+ tenantFeature: 'patients'
+ }
},
- // CADASTROS RECEBIDOS
+ // 📥 Cadastros recebidos via link externo
{
path: 'pacientes/cadastro/recebidos',
- name: 'admin.pacientes.recebidos',
- component: () => import('@/views/pages/admin/pacientes/cadastro/recebidos/CadastrosRecebidosPage.vue')
+ name: 'admin-pacientes-recebidos',
+ component: () => import('@/features/patients/cadastro/recebidos/CadastrosRecebidosPage.vue'),
+ meta: {
+ tenantFeature: 'patients'
+ }
},
- // SEGURANÇA
+ // ======================================================
+ // 🔐 SEGURANÇA
+ // ======================================================
{
path: 'settings/security',
name: 'admin-settings-security',
component: () => import('@/views/pages/auth/SecurityPage.vue')
},
- // ================================
+ // ======================================================
// 🔒 MÓDULO PRO — Online Scheduling
- // ================================
- // Admin também gerencia agendamento online; mesma feature de gestão.
- // Você pode ter uma página admin para isso, ou reaproveitar a do therapist.
+ // ======================================================
{
path: 'online-scheduling',
name: 'admin-online-scheduling',
@@ -90,4 +177,4 @@ export default {
}
}
]
-}
+}
\ No newline at end of file
diff --git a/src/router/router.configuracoes.js b/src/router/routes.configs.js
similarity index 96%
rename from src/router/router.configuracoes.js
rename to src/router/routes.configs.js
index a2dbb24..7c0844a 100644
--- a/src/router/router.configuracoes.js
+++ b/src/router/routes.configs.js
@@ -1,4 +1,4 @@
-// src/router/router.configuracoes.js
+// src/router/router.configs.js
import AppLayout from '@/layout/AppLayout.vue'
const configuracoesRoutes = {
diff --git a/src/router/routes.features.js b/src/router/routes.features.js
new file mode 100644
index 0000000..d07ed67
--- /dev/null
+++ b/src/router/routes.features.js
@@ -0,0 +1,26 @@
+// src/router/routes.features.js
+import AppLayout from '@/layout/AppLayout.vue'
+
+export default {
+ path: '/features',
+ component: AppLayout,
+ meta: { requiresAuth: true }, // roles: se você quiser travar aqui também
+ children: [
+ // Patients
+ {
+ path: 'patients',
+ name: 'features.patients.list',
+ component: () => import('@/features/patients/PatientsListPage.vue') // ajuste se seu arquivo tiver outro nome
+ },
+ {
+ path: 'patients/cadastro',
+ name: 'features.patients.create',
+ component: () => import('@/features/patients/cadastro/PatientsCadastroPage.vue')
+ },
+ {
+ path: 'patients/cadastro/:id',
+ name: 'features.patients.edit',
+ component: () => import('@/features/patients/cadastro/PatientsCadastroPage.vue')
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/router/routes.patient.js b/src/router/routes.patient.js
deleted file mode 100644
index cf49ccd..0000000
--- a/src/router/routes.patient.js
+++ /dev/null
@@ -1,19 +0,0 @@
-import AppLayout from '@/layout/AppLayout.vue'
-
-export default {
- path: '/patient',
- component: AppLayout,
- meta: { requiresAuth: true, role: 'patient' },
- children: [
- {
- path: '',
- name: 'patient-dashboard',
- component: () => import('@/views/pages/patient/PatientDashboard.vue')
- },
- {
- path: 'settings/security',
- name: 'patient-settings-security',
- component: () => import('@/views/pages/auth/SecurityPage.vue')
- }
- ]
-}
diff --git a/src/router/routes.portal.js b/src/router/routes.portal.js
new file mode 100644
index 0000000..8ddd6a4
--- /dev/null
+++ b/src/router/routes.portal.js
@@ -0,0 +1,27 @@
+// src/router/router.portal.js
+import AppLayout from '@/layout/AppLayout.vue'
+
+export default {
+ path: '/portal',
+ component: AppLayout,
+ meta: { requiresAuth: true, roles: ['patient'] },
+ children: [
+ {
+ path: '',
+ name: 'portal-dashboard',
+ component: () => import('@/views/pages/portal/PortalDashboard.vue')
+ },
+
+ // ✅ Appointments (era agenda)
+ {
+ path: 'agenda',
+ name: 'portal-agenda',
+ component: () => import('@/views/pages/portal/agenda/MyAppointmentsPage.vue')
+ },
+ {
+ path: 'agenda/new',
+ name: 'portal-agenda-new',
+ component: () => import('@/views/pages/portal/agenda/NewAppointmentPage.vue')
+ }
+ ]
+}
\ No newline at end of file
diff --git a/src/router/routes.public.js b/src/router/routes.public.js
index 26496b5..28a12aa 100644
--- a/src/router/routes.public.js
+++ b/src/router/routes.public.js
@@ -21,6 +21,13 @@ export default {
name: 'public.patient.intake',
component: () => import('@/views/pages/public/CadastroPacienteExterno.vue'),
meta: { public: true }
- }
+ },
+ // ✅ convite de clinicas
+ {
+ path: '/accept-invite',
+ name: 'accept-invite',
+ component: () => import('@/views/pages/public/AcceptInvitePage.vue'),
+ meta: { public: true }
+ }
]
};
diff --git a/src/router/routes.therapist.js b/src/router/routes.therapist.js
index 9352e7f..d042932 100644
--- a/src/router/routes.therapist.js
+++ b/src/router/routes.therapist.js
@@ -1,3 +1,4 @@
+// src/router/routes.therapist.js
import AppLayout from '@/layout/AppLayout.vue'
export default {
@@ -8,34 +9,99 @@ export default {
// 🔐 Tudo aqui dentro exige login
requiresAuth: true,
- // 👤 Perfil de acesso (seu guard atual usa meta.role)
- role: 'therapist'
+ // 👤 Perfil de acesso (tenant-level)
+ roles: ['therapist']
},
children: [
- // ======================
- // ✅ Dashboard Therapist
- // ======================
+ // ======================================================
+ // 📊 DASHBOARD
+ // ======================================================
{
path: '',
name: 'therapist-dashboard',
component: () => import('@/views/pages/therapist/TherapistDashboard.vue')
- // herda requiresAuth + role do pai
+ // herda requiresAuth + roles do pai
},
- // ======================
- // ✅ Segurança
- // ======================
+ // ======================================================
+ // 📅 AGENDA
+ // ======================================================
{
- path: 'settings/security',
- name: 'therapist-settings-security',
- component: () => import('@/views/pages/auth/SecurityPage.vue')
- // herda requiresAuth + role do pai
+ path: 'agenda',
+ name: 'therapist-agenda',
+ //component: () => import('@/views/pages/therapist/agenda/MyAppointmentsPage.vue'),
+ component: () => import('@/features/agenda/pages/AgendaTerapeutaPage.vue'),
+ meta: {
+ feature: 'agenda.view'
+ }
},
- // ==========================================
+ {
+ path: 'agenda/adicionar',
+ name: 'therapist-agenda-adicionar',
+ component: () => import('@/views/pages/therapist/agenda/NewAppointmentPage.vue'),
+ meta: {
+ feature: 'agenda.manage'
+ }
+ },
+
+ // ======================================================
+ // 👥 PATIENTS
+ // ======================================================
+
+ {
+ path: 'patients',
+ name: 'therapist-patients',
+ component: () => import('@/features/patients/PatientsListPage.vue')
+ },
+
+ // ➕ Create patient
+ {
+ path: 'patients/cadastro',
+ name: 'therapist-patients-create',
+ component: () => import('@/features/patients/cadastro/PatientsCadastroPage.vue')
+ },
+
+ {
+ path: 'patients/cadastro/:id',
+ name: 'therapist-patients-edit',
+ component: () => import('@/features/patients/cadastro/PatientsCadastroPage.vue'),
+ props: true
+ },
+
+ // 👥 Groups
+ {
+ path: 'patients/grupos',
+ name: 'therapist-patients-groups',
+ component: () => import('@/features/patients/grupos/GruposPacientesPage.vue')
+ },
+
+ // 🏷️ Tags
+ {
+ path: 'patients/tags',
+ name: 'therapist-patients-tags',
+ component: () => import('@/features/patients/tags/TagsPage.vue')
+ },
+
+ // 🔗 External Link
+ {
+ path: 'patients/link-externo',
+ name: 'therapist-patients-link-externo',
+ component: () => import('@/features/patients/cadastro/PatientsExternalLinkPage.vue')
+ },
+
+ // 📥 Received Registrations
+ {
+ path: 'patients/cadastro/recebidos',
+ name: 'therapist-patients-recebidos',
+ component: () =>
+ import('@/features/patients/cadastro/recebidos/CadastrosRecebidosPage.vue')
+ },
+
+ // ======================================================
// 🔒 PRO — Online Scheduling (gestão interna)
- // ==========================================
+ // ======================================================
// feature gate via meta.feature:
// - bloqueia rota (guard)
// - menu pode desabilitar/ocultar (entitlementsStore.has)
@@ -44,23 +110,23 @@ export default {
name: 'therapist-online-scheduling',
component: () => import('@/views/pages/therapist/OnlineSchedulingPage.vue'),
meta: {
- // ✅ herda requiresAuth + role do pai
feature: 'online_scheduling.manage'
}
},
- // =================================================
- // 🔒 PRO — Online Scheduling (página pública/config)
- // =================================================
- // Se você tiver/for criar a tela para configurar/visualizar a página pública,
- // use a chave granular:
- // - online_scheduling.public
- //
- // Dica de produto:
- // - "manage" = operação interna
- // - "public" = ajustes/preview/links
- //
- // Quando criar o arquivo, descomente.
+ // ======================================================
+ // 🔐 SECURITY (temporário dentro da área)
+ // ======================================================
+ // ⚠️ Idealmente mover para /account/security (área global)
+ {
+ path: 'settings/security',
+ name: 'therapist-settings-security',
+ component: () => import('@/views/pages/auth/SecurityPage.vue')
+ }
+
+ // ======================================================
+ // 🔒 PRO — Online Scheduling (configuração pública)
+ // ======================================================
// {
// path: 'online-scheduling/public',
// name: 'therapist-online-scheduling-public',
@@ -68,4 +134,4 @@ export default {
// meta: { feature: 'online_scheduling.public' }
// }
]
-}
+}
\ No newline at end of file
diff --git a/src/sql-arquivos/supabase_patient_index_page.sql b/src/sql-arquivos/supabase_patient_index_page.sql
index c8def53..6a6dbd5 100644
--- a/src/sql-arquivos/supabase_patient_index_page.sql
+++ b/src/sql-arquivos/supabase_patient_index_page.sql
@@ -1,6 +1,6 @@
-- =========================================================
-- pacientesIndexPage.sql
--- Views + índices para a tela PatientsIndexPage
+-- Views + índices para a tela PatientsListPage
-- =========================================================
-- 0) Extensões úteis
diff --git a/src/stores/tenantFeaturesStore.js b/src/stores/tenantFeaturesStore.js
new file mode 100644
index 0000000..b87982a
--- /dev/null
+++ b/src/stores/tenantFeaturesStore.js
@@ -0,0 +1,69 @@
+import { defineStore } from 'pinia'
+import { ref } from 'vue'
+import { supabase } from '@/lib/supabase/client'
+
+export const useTenantFeaturesStore = defineStore('tenantFeatures', () => {
+ const loading = ref(false)
+ const loadedForTenantId = ref(null)
+ const features = ref({}) // { patients: true/false, ... }
+
+ function isEnabled(key) {
+ return !!features.value?.[key]
+ }
+
+ function invalidate() {
+ loadedForTenantId.value = null
+ features.value = {}
+ }
+
+ async function fetchForTenant(tenantId, { force = false } = {}) {
+ if (!tenantId) return
+ if (!force && loadedForTenantId.value === tenantId) return
+
+ loading.value = true
+ try {
+ const { data, error } = await supabase
+ .from('tenant_features')
+ .select('feature_key, enabled')
+ .eq('tenant_id', tenantId)
+
+ if (error) throw error
+
+ const map = {}
+ for (const row of data || []) map[row.feature_key] = !!row.enabled
+
+ features.value = map
+ loadedForTenantId.value = tenantId
+ } finally {
+ loading.value = false
+ }
+ }
+
+ async function setForTenant(tenantId, key, enabled) {
+ if (!tenantId) throw new Error('tenantId missing')
+
+ const { error } = await supabase
+ .from('tenant_features')
+ .upsert(
+ { tenant_id: tenantId, feature_key: key, enabled: !!enabled },
+ { onConflict: 'tenant_id,feature_key' }
+ )
+
+ if (error) throw error
+
+ // atualiza cache local
+ if (loadedForTenantId.value === tenantId) {
+ features.value = { ...features.value, [key]: !!enabled }
+ }
+ }
+
+ return {
+ loading,
+ features,
+ loadedForTenantId,
+ isEnabled,
+ invalidate,
+ fetchForTenant,
+ setForTenant
+ }
+})
\ No newline at end of file
diff --git a/src/stores/tenantStore.js b/src/stores/tenantStore.js
index 2fe13bf..67df939 100644
--- a/src/stores/tenantStore.js
+++ b/src/stores/tenantStore.js
@@ -2,6 +2,21 @@
import { defineStore } from 'pinia'
import { supabase } from '@/lib/supabase/client'
+// ✅ normaliza roles vindas do backend (tenant_members / RPC my_tenants)
+// - seu projeto quer usar clinic_admin como nome canônico
+function normalizeTenantRole (role) {
+ const r = String(role || '').trim()
+ if (!r) return null
+
+ // ✅ legado: alguns bancos / RPCs retornam tenant_admin
+ if (r === 'tenant_admin') return 'clinic_admin'
+
+ // (opcional) se em algum lugar vier 'admin' (profiles), também normaliza:
+ if (r === 'admin') return 'clinic_admin'
+
+ return r
+}
+
export const useTenantStore = defineStore('tenant', {
state: () => ({
loading: false,
@@ -11,76 +26,136 @@ export const useTenantStore = defineStore('tenant', {
memberships: [], // [{ tenant_id, role, status }]
activeTenantId: null,
activeRole: null,
-
- needsTenantLink: false,
+
+ needsTenantLink: false,
error: null
}),
actions: {
async loadSessionAndTenant () {
- if (this.loading) return
- this.loading = true
- this.error = null
+ if (this.loading) return
+ this.loading = true
+ this.error = null
- try {
- // 1) auth user (estável)
- const { data, error } = await supabase.auth.getSession()
- if (error) throw error
+ try {
+ // 1) auth user (estável)
+ const { data, error } = await supabase.auth.getSession()
+ if (error) throw error
- this.user = data?.session?.user ?? null
+ this.user = data?.session?.user ?? null
- // sem sessão -> não chama RPC, só marca estado
- if (!this.user) {
- this.memberships = []
- this.activeTenantId = null
- this.activeRole = null
- this.needsTenantLink = false
- this.loaded = true
- return
- }
+ // sem sessão -> limpa estado e storage
+ if (!this.user) {
+ this.memberships = []
+ this.activeTenantId = null
+ this.activeRole = null
+ this.needsTenantLink = false
+ this.loaded = true
- // 2) memberships via RPC
- const { data: mem, error: mErr } = await supabase.rpc('my_tenants')
- if (mErr) throw mErr
+ localStorage.removeItem('tenant_id')
+ localStorage.removeItem('tenant')
- this.memberships = Array.isArray(mem) ? mem : []
+ return
+ }
- // 3) define active tenant (primeiro active)
- const firstActive = this.memberships.find(x => x.status === 'active')
- this.activeTenantId = firstActive?.tenant_id ?? null
- this.activeRole = firstActive?.role ?? null
+ // 2) memberships via RPC
+ const { data: mem, error: mErr } = await supabase.rpc('my_tenants')
+ if (mErr) throw mErr
- // se logou mas não tem vínculo ativo
- this.needsTenantLink = !this.activeTenantId
+ this.memberships = Array.isArray(mem) ? mem : []
- this.loaded = true
- } catch (e) {
- console.warn('[tenantStore] loadSessionAndTenant falhou:', e)
- this.error = e
+ // 3) tenta restaurar tenant salvo
+ const savedTenantId = localStorage.getItem('tenant_id')
- // ⚠️ NÃO zera tudo agressivamente por erro transitório.
- // Mantém o que já tinha (se tiver), mas marca loaded pra não travar o app.
- // Se você preferir ser mais “duro”, só zere quando não houver sessão:
- // (a sessão já foi lida acima; se der erro antes, user pode estar null)
- if (!this.user) {
- this.memberships = []
- this.activeTenantId = null
- this.activeRole = null
- this.needsTenantLink = false
- }
+ let activeMembership = null
- this.loaded = true
- } finally {
- this.loading = false
- }
-}
-,
+ if (savedTenantId) {
+ activeMembership = this.memberships.find(
+ x => x.tenant_id === savedTenantId && x.status === 'active'
+ )
+ }
+
+ // fallback: primeiro active
+ if (!activeMembership) {
+ activeMembership = this.memberships.find(x => x.status === 'active')
+ }
+
+ this.activeTenantId = activeMembership?.tenant_id ?? null
+
+ // ✅ normaliza role aqui (tenant_admin -> clinic_admin)
+ this.activeRole = normalizeTenantRole(activeMembership?.role)
+
+ // persiste tenant se existir
+ if (this.activeTenantId) {
+ localStorage.setItem('tenant_id', this.activeTenantId)
+ localStorage.setItem('tenant', JSON.stringify({
+ id: this.activeTenantId,
+ role: this.activeRole
+ }))
+ }
+
+ // se logou mas não tem vínculo ativo
+ this.needsTenantLink = !this.activeTenantId
+
+ this.loaded = true
+ } catch (e) {
+ console.warn('[tenantStore] loadSessionAndTenant falhou:', e)
+ this.error = e
+
+ // ⚠️ NÃO zera tudo agressivamente por erro transitório.
+ // Mantém o que já tinha (se tiver), mas marca loaded pra não travar o app.
+ if (!this.user) {
+ this.memberships = []
+ this.activeTenantId = null
+ this.activeRole = null
+ this.needsTenantLink = false
+
+ localStorage.removeItem('tenant_id')
+ localStorage.removeItem('tenant')
+ }
+
+ this.loaded = true
+ } finally {
+ this.loading = false
+ }
+ },
setActiveTenant (tenantId) {
- const found = this.memberships.find(x => x.tenant_id === tenantId && x.status === 'active')
- this.activeTenantId = found?.tenant_id ?? null
- this.activeRole = found?.role ?? null
- this.needsTenantLink = !this.activeTenantId
- }
+ const found = this.memberships.find(
+ x => x.tenant_id === tenantId && x.status === 'active'
+ )
+
+ this.activeTenantId = found?.tenant_id ?? null
+
+ // ✅ normaliza role também ao trocar tenant
+ this.activeRole = normalizeTenantRole(found?.role)
+
+ this.needsTenantLink = !this.activeTenantId
+
+ if (this.activeTenantId) {
+ localStorage.setItem('tenant_id', this.activeTenantId)
+ localStorage.setItem('tenant', JSON.stringify({
+ id: this.activeTenantId,
+ role: this.activeRole
+ }))
+ } else {
+ localStorage.removeItem('tenant_id')
+ localStorage.removeItem('tenant')
+ }
+ },
+
+ // opcional mas recomendado
+ reset () {
+ this.user = null
+ this.memberships = []
+ this.activeTenantId = null
+ this.activeRole = null
+ this.needsTenantLink = false
+ this.error = null
+ this.loaded = false
+
+ localStorage.removeItem('tenant_id')
+ localStorage.removeItem('tenant')
+ }
}
-})
+})
\ No newline at end of file
diff --git a/src/views/pages/HomeCards.vue b/src/views/pages/HomeCards.vue
index d4d9d1f..d45f15f 100644
--- a/src/views/pages/HomeCards.vue
+++ b/src/views/pages/HomeCards.vue
@@ -2,68 +2,76 @@
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { supabase } from '../../lib/supabase/client' // ajuste se o caminho for outro
+import { useTenantStore } from '@/stores/tenantStore'
const router = useRouter()
+const tenant = useTenantStore()
const checking = ref(true)
const userEmail = ref('')
-const role = ref(null)
+const role = ref(null) // aqui vai guardar o role REAL do tenant: clinic_admin/therapist/patient
const TEST_ACCOUNTS = {
- admin: { email: 'admin@agenciapsi.com.br', password: '123Mudar@' },
+ clinic_admin: { email: 'clinic@agenciapsi.com.br', password: '123Mudar@' },
therapist: { email: 'therapist@agenciapsi.com.br', password: '123Mudar@' },
- patient: { email: 'patient@agenciapsi.com.br', password: '123Mudar@' }
+ patient: { email: 'patient@agenciapsi.com.br', password: '123Mudar@' },
+ saas: { email: 'saas@agenciapsi.com.br', password: '123Mudar@' }
}
-
-function roleToPath(r) {
- if (r === 'admin') return '/admin'
+function roleToPath (r) {
+ // ✅ role REAL (tenant_members via my_tenants)
+ if (r === 'clinic_admin' || r === 'tenant_admin' || r === 'admin') return '/admin'
if (r === 'therapist') return '/therapist'
- if (r === 'patient') return '/patient'
+ if (r === 'patient') return '/portal'
return '/'
}
-async function fetchMyRole() {
+async function isSaasAdmin () {
const { data: userData, error: userErr } = await supabase.auth.getUser()
- if (userErr) return null
+ if (userErr) return false
const user = userData?.user
- if (!user) return null
-
- userEmail.value = user.email || ''
+ if (!user?.id) return false
const { data, error } = await supabase
- .from('profiles')
- .select('role')
- .eq('id', user.id)
- .single()
+ .from('saas_admins')
+ .select('user_id')
+ .eq('user_id', user.id)
+ .maybeSingle()
- if (error) return null
- return data?.role || null
+ if (error) return false
+ return !!data
}
-async function go(area) {
- // Se já estiver logado, respeita role real (não o card)
+// ✅ carrega tenant/role real (my_tenants) e atualiza UI
+async function syncTenantRole () {
+ await tenant.loadSessionAndTenant()
+ role.value = tenant.activeRole || null
+ return role.value
+}
+
+async function go (area) {
+ // Se já estiver logado:
const { data: sessionData } = await supabase.auth.getSession()
const session = sessionData?.session
if (session) {
- const r = role.value || (await fetchMyRole())
+ userEmail.value = session.user?.email || userEmail.value || ''
+
+ // ✅ se for SaaS master, SEMPRE manda pra /saas (independente do card clicado)
+ const saas = await isSaasAdmin()
+ if (saas) return router.push('/saas')
+
+ const r = role.value || (await syncTenantRole())
if (!r) return router.push('/auth/login')
return router.push(roleToPath(r))
}
// Se não estiver logado, manda pro login guardando a intenção
- sessionStorage.setItem('intended_area', area) // admin/therapist/patient
+ sessionStorage.setItem('intended_area', area) // clinic_admin/therapist/patient/saas
// ✅ Prefill de login (apenas DEV)
const DEV_PREFILL = import.meta.env.DEV
if (DEV_PREFILL) {
- const TEST_ACCOUNTS = {
- admin: { email: 'admin@agenciapsi.com.br', password: '123Mudar@' },
- therapist: { email: 'therapist@agenciapsi.com.br', password: '123Mudar@' },
- patient: { email: 'patient@agenciapsi.com.br', password: '123Mudar@' }
- }
-
const acc = TEST_ACCOUNTS[area]
if (acc) {
sessionStorage.setItem('login_prefill_email', acc.email)
@@ -77,15 +85,32 @@ async function go(area) {
router.push('/auth/login')
}
-async function goMyPanel() {
+async function goMyPanel () {
if (!role.value) return
+
+ // ✅ se for SaaS master, sempre /saas
+ const saas = await isSaasAdmin()
+ if (saas) return router.push('/saas')
+
router.push(roleToPath(role.value))
}
-async function logout() {
- await supabase.auth.signOut()
- role.value = null
- userEmail.value = ''
+async function logout () {
+ try {
+ await supabase.auth.signOut()
+ } finally {
+ role.value = null
+ userEmail.value = ''
+
+ // limpa qualquer intenção pendente
+ sessionStorage.removeItem('redirect_after_login')
+ sessionStorage.removeItem('intended_area')
+
+ // ✅ força redirecionamento para HomeCards (/)
+ router.replace('/')
+ // Use router.replace('/') e não push,
+ // assim o usuário não consegue voltar com o botão "voltar" para uma rota protegida.
+ }
}
onMounted(async () => {
@@ -94,7 +119,17 @@ onMounted(async () => {
const session = sessionData?.session
if (session) {
- role.value = await fetchMyRole()
+ userEmail.value = session.user?.email || ''
+
+ // ✅ se for SaaS master, manda direto pro SaaS
+ const saas = await isSaasAdmin()
+ if (saas) {
+ router.replace('/saas')
+ return
+ }
+
+ // ✅ role REAL vem do tenantStore (my_tenants)
+ role.value = await syncTenantRole()
// Se está logado e tem role, manda direto pro painel
if (role.value) {
@@ -201,15 +236,15 @@ onMounted(async () => {
-
-
+
+
- Admin
+ Clínica
@@ -225,7 +260,7 @@ onMounted(async () => {
-
+
{
-
+
{
+
+
+
+
+
+ SaaS (Master)
+
+
+
+
+
+ Acesso global: planos, assinaturas, tenants e saúde da plataforma.
+
+
+
+ Acessar painel →
+
+
+
+
+
+
+
+
+
+
+ Usuários do ambiente (Desenvolvimento)
+
+
+ Identificadores internos
+
+
+
+
+
+
+
+
ID
+
E-mail
+
+
+
+
+
+
+ 40a4b683-a0c9-4890-a201-20faf41fca06
+
+
+ saas@agenciapsi.com.br
+
+
+
+
+
+ 523003e7-17ab-4375-b912-040027a75c22
+
+
+ patient@agenciapsi.com.br
+
+
+
+
+
+ 816b24fe-a0c3-4409-b79b-c6c0a6935d03
+
+
+ clinic@agenciapsi.com.br
+
+
+
+
+
+ 824f125c-55bb-40f5-a8c4-7a33618b91c7
+
+
+ therapist@agenciapsi.com.br
+
+
+
+
+
+
+
+
+ Estes usuários existem apenas para fins de teste no ambiente de desenvolvimento.
+
+
+
diff --git a/src/views/pages/me/MeuPerfilPage.vue b/src/views/pages/account/ProfilePage.vue
similarity index 100%
rename from src/views/pages/me/MeuPerfilPage.vue
rename to src/views/pages/account/ProfilePage.vue
diff --git a/src/views/pages/admin/agenda/MyAppointmentsPage.vue b/src/views/pages/admin/agenda/MyAppointmentsPage.vue
new file mode 100644
index 0000000..e44e41b
--- /dev/null
+++ b/src/views/pages/admin/agenda/MyAppointmentsPage.vue
@@ -0,0 +1,9 @@
+
+
+
My Appointments
+
+
+
+
\ No newline at end of file
diff --git a/src/views/pages/admin/agenda/NewAppointmentPage.vue b/src/views/pages/admin/agenda/NewAppointmentPage.vue
new file mode 100644
index 0000000..ee6abb6
--- /dev/null
+++ b/src/views/pages/admin/agenda/NewAppointmentPage.vue
@@ -0,0 +1,9 @@
+
+
+
Add New Appointments
+
+
+
+
\ No newline at end of file
diff --git a/src/views/pages/admin/clinic/ClinicFeaturesPage.vue b/src/views/pages/admin/clinic/ClinicFeaturesPage.vue
new file mode 100644
index 0000000..82d453d
--- /dev/null
+++ b/src/views/pages/admin/clinic/ClinicFeaturesPage.vue
@@ -0,0 +1,330 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Módulos da Clínica
+
+ Ative/desative recursos por clínica. Isso controla menu, rotas (guard) e acesso no banco (RLS).
+
+ Para aluguel de salas: sem pacientes, com salas.
+
+
+
+
+
+
+
+
+
+
+
+
Preset: Clínica com recepção
+
+ Para secretária gerenciar agenda (pacientes opcional).
+
+
+
+
+
+
+
+
+
+
+
+
Preset: Clínica completa
+
+ Pacientes + recepção + salas (se quiser).
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Quando desligado:
+
+
Menu “Pacientes” some.
+
Rotas com meta.tenantFeature = 'patients' redirecionam pra cá.
+
RLS bloqueia acesso direto no banco.
+
+
+
+
+
+
+
+
+
+
+ Observação: este módulo é “produto” (UX + permissões). A base aqui é só o toggle.
+ Depois a gente cria:
+
+
role secretary em tenant_members
+
policies e telas para a secretária
+
nível de visibilidade do paciente na agenda
+
+
+
+
+
+
+
+
+
+
+ Isso prepara o terreno para a clínica operar como locação de sala, com agenda vinculando sala + profissional.
+
+
+
+
+
+
+
+
+
+ Você já tem páginas de link externo. Isso vira o controle fino: a clínica decide se usa ou não.
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/views/pages/admin/clinic/ClinicProfessionalsPage.vue b/src/views/pages/admin/clinic/ClinicProfessionalsPage.vue
new file mode 100644
index 0000000..81e3bbf
--- /dev/null
+++ b/src/views/pages/admin/clinic/ClinicProfessionalsPage.vue
@@ -0,0 +1,1113 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Profissionais da clínica
+
+
+ Gerencie terapeutas e secretarias vinculados ao seu tenant.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Atenção
+
+ A regra “impedir desvincular terapeuta com atendimentos agendados” será ativada quando a agenda
+ registrar o terapeuta no evento (ex.: agenda_eventos.terapeuta_id)
+ ou quando existir a tabela de sessões/appointments. Por enquanto, a ação de desvincular apenas
+ desativa o vínculo.
+
+
+
+
+
+
+
+ Carregando permissões da clínica…
+
+
+
+
+ Sua conta não tem permissão para gerenciar profissionais (apenas clinic_admin).
+
+ Esta área existe para facilitar o QA/validação do fluxo de convites no SaaS multi-tenant.
+ A tela pública de aceite está em:
+ /accept-invite?token=<uuid>.
+ O login fica em /auth/login.
+
+
+
+
+
Rotas e comportamento esperado
+
+
+ Aceite público:/accept-invite?token=<uuid>
+
+
+ Login:/auth/login
+
+
+ Se NÃO logado: salva token em sessionStorage(pending_invite_token_v1) → redireciona para login → após login retoma o aceite automaticamente.
+
+
+ Se logado: chama RPC tenant_accept_invite(p_token) → sucesso: cria/ativa membership → redireciona para /admin.
+
+ Nota: o backend foi corrigido para não depender do claim de email no JWT
+ (erro antigo missing_email_claim). O email é resolvido via
+ auth.users usando SECURITY DEFINER.
+
+
+
+
+
Como testar (prático)
+
+
Convidar alguém nesta tela (botões acima).
+
Abrir a aba Convites e copiar o link.
+
Abrir o link em aba anônima → logar com o mesmo email → aceitar.
+
Voltar aqui: a pessoa sai de Convites e aparece em Equipe.
+
+
+
+
+
+
+
+
+
+
+ Dica: use aba anônima para testar o fluxo completo sem interferência de sessão.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Equipe
+
+ Membros ativos/inativos do tenant (somente tenant_members).
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ loadError }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ data.full_name || 'Sem nome' }}
+
+
+
+ {{ data.email || 'Sem email' }}
+
+
+
+ tenant: {{ data.tenant_id }}
+
+
+
+ uid: {{ data.user_id }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Você
+
+
+
+
+
+
+ Nenhum profissional encontrado.
+
+
+
+
+
+ Papel real salvo em tenant_members.role:
+ tenant_admin, therapist, secretary, patient.
+ No front, normalizamos tenant_admin → clinic_admin (apenas para UI).
+
+
+
+ Status:active = acesso liberado.
+ inactive = vínculo desativado (histórico).
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Convites pendentes
+
+ Convites do tenant que ainda não foram aceitos (tabela tenant_invites).
+
+
+
+
+
+ Modelo B: convidar não cria membership. O membership só aparece na aba Equipe após o aceite em
+ /accept-invite?token=....
+
+
+
+
+
+
+
+
+
Usuários cadastrados (Auth)
+
+
+
+
+
+
+
+
+
+
+ Aviso técnico — View temporária para testes (QA)
+
+
+
+ Para permitir a listagem de usuários cadastrados no sistema,
+ foi criada a view public.v_auth_users_public.
+
+
+
+ Motivo: o schema auth não pode ser acessado diretamente pelo Supabase client
+ (ex: .from('auth.users')).
+ A view expõe apenas campos necessários para QA (email/uid/datas).
+
+
+
+ ⚠️ Remover após validação:
+
+
+
+ drop view if exists public.v_auth_users_public;
+
+
+
+
+
+
+ {{ loadUsersError }}
+
+
+
+
+
+
+ {{ data.user_id }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/views/pages/auth/Login.vue b/src/views/pages/auth/Login.vue
index 92fa79c..a8b26b6 100644
--- a/src/views/pages/auth/Login.vue
+++ b/src/views/pages/auth/Login.vue
@@ -13,6 +13,9 @@ import Button from 'primevue/button'
import Dialog from 'primevue/dialog'
import { useToast } from 'primevue/usetoast'
+// ✅ sessão (fonte de verdade p/ saas admin)
+import { initSession, sessionIsSaasAdmin } from '@/app/session'
+
const tenant = useTenantStore()
const toast = useToast()
const router = useRouter()
@@ -39,9 +42,10 @@ function isEmail (v) {
}
function roleToPath (role) {
- if (role === 'tenant_admin') return '/admin'
+ // ✅ aceita os dois nomes (seu banco está devolvendo tenant_admin)
+ if (role === 'clinic_admin' || role === 'tenant_admin' || role === 'admin') return '/admin'
if (role === 'therapist') return '/therapist'
- if (role === 'patient') return '/patient'
+ if (role === 'patient') return '/portal'
return '/'
}
@@ -69,6 +73,31 @@ async function onSubmit () {
if (res.error) throw res.error
+ // ✅ garante que sessionIsSaasAdmin esteja hidratado após login
+ // (evita cair no fluxo de tenant quando o usuário é SaaS master)
+ try {
+ await initSession({ initial: false })
+ } catch (e) {
+ console.warn('[Login] initSession pós-login falhou:', e)
+ // não aborta login por isso
+ }
+
+ // lembrar e-mail (não senha)
+ persistRememberedEmail()
+
+ // ✅ prioridade: redirect_after_login (se existir)
+ // mas antes, se for SaaS admin, NÃO exigir tenant.
+ const redirect = sessionStorage.getItem('redirect_after_login')
+ if (sessionIsSaasAdmin.value) {
+ if (redirect) {
+ sessionStorage.removeItem('redirect_after_login')
+ router.push(redirect)
+ return
+ }
+ router.push('/saas')
+ return
+ }
+
// ✅ agora que está autenticado, garante tenant pessoal (Modelo B)
try {
await supabase.rpc('ensure_personal_tenant')
@@ -91,10 +120,7 @@ async function onSubmit () {
return
}
- // lembrar e-mail (não senha)
- persistRememberedEmail()
-
- const redirect = sessionStorage.getItem('redirect_after_login')
+ // ✅ se havia redirect, vai pra ele
if (redirect) {
sessionStorage.removeItem('redirect_after_login')
router.push(redirect)
@@ -119,7 +145,6 @@ async function onSubmit () {
}
}
-
function openForgot () {
recoverySent.value = false
recoveryEmail.value = email.value?.trim() || ''
@@ -214,33 +239,33 @@ onMounted(() => {
-
+
\ No newline at end of file
diff --git a/src/views/pages/billing/UpgradePage.vue b/src/views/pages/billing/UpgradePage.vue
index 5d37250..6943d80 100644
--- a/src/views/pages/billing/UpgradePage.vue
+++ b/src/views/pages/billing/UpgradePage.vue
@@ -70,13 +70,16 @@ const enabledFeatureIdsByPlanId = computed(() => {
const currentPlanId = computed(() => subscription.value?.plan_id || null)
-function planKeyById(id) {
+function planKeyById (id) {
return planById.value.get(id)?.key || null
}
-const currentPlanKey = computed(() => planKeyById(currentPlanId.value))
+const currentPlanKey = computed(() => {
+ // ✅ fallback: se não carregou plans ainda, usa o plan_key da subscription
+ return planKeyById(currentPlanId.value) || subscription.value?.plan_key || null
+})
-function friendlyFeatureLabel(featureKey) {
+function friendlyFeatureLabel (featureKey) {
return featureLabels[featureKey] || featureKey
}
@@ -92,7 +95,7 @@ const sortedPlans = computed(() => {
return arr
})
-function planBenefits(planId) {
+function planBenefits (planId) {
const set = enabledFeatureIdsByPlanId.value.get(planId) || new Set()
const list = features.value.map((f) => ({
ok: set.has(f.id),
@@ -104,34 +107,82 @@ function planBenefits(planId) {
return list
}
-function goBack() {
+function goBack () {
router.back()
}
-function goBilling() {
+function goBilling () {
router.push('/admin/billing')
}
-function contactSupport() {
+function contactSupport () {
router.push('/admin/billing')
}
-async function fetchAll() {
+// ✅ revalida a rota atual para o guard reavaliar features após troca de plano
+async function revalidateCurrentRoute () {
+ // tenta respeitar um redirectTo (quando usuário veio por recurso bloqueado)
+ const redirectTo = route.query.redirectTo ? String(route.query.redirectTo) : null
+
+ // se existe redirectTo, tente ir para ele (guard decide se entra ou volta ao upgrade)
+ if (redirectTo) {
+ try {
+ await router.replace(redirectTo)
+ return
+ } catch (_) {
+ // se falhar, cai no refresh da rota atual
+ }
+ }
+
+ // força o vue-router a reprocessar a rota (dispara beforeEach)
+ try {
+ await router.replace(router.currentRoute.value.fullPath)
+ } catch (_) {}
+}
+
+async function fetchAll () {
loading.value = true
try {
const tid = tenantId.value
if (!tid) throw new Error('Tenant ativo não encontrado.')
const [pRes, fRes, pfRes, sRes] = await Promise.all([
- supabase.from('plans').select('*').order('key', { ascending: true }),
+ supabase.from('plans').select('*').eq('is_active', true).order('key', { ascending: true }),
supabase.from('features').select('*').order('key', { ascending: true }),
supabase.from('plan_features').select('plan_id, feature_id'),
supabase
.from('subscriptions')
- .select('id, tenant_id, plan_id, plan_key, interval, status, created_at, updated_at')
+ // ✅ pega mais campos úteis e faz join do plano (ajuda a exibir e debugar)
+ .select(`
+ id,
+ tenant_id,
+ user_id,
+ plan_id,
+ plan_key,
+ "interval",
+ status,
+ provider,
+ source,
+ started_at,
+ current_period_start,
+ current_period_end,
+ created_at,
+ updated_at,
+ plan:plan_id (
+ id,
+ key,
+ name,
+ description,
+ price_cents,
+ currency,
+ billing_interval,
+ is_active
+ )
+ `)
.eq('tenant_id', tid)
.eq('status', 'active')
- .order('updated_at', { ascending: false })
+ // ✅ created_at é mais confiável que updated_at em assinaturas manuais
+ .order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
])
@@ -142,13 +193,20 @@ async function fetchAll() {
// ✅ subscription pode ser null sem quebrar a página
if (sRes.error) {
- console.warn('[Upgrade] sem subscription ativa (ok):', sRes.error)
+ console.warn('[Upgrade] erro ao buscar subscription:', sRes.error)
}
plans.value = pRes.data || []
features.value = fRes.data || []
planFeatures.value = pfRes.data || []
subscription.value = sRes.data || null
+
+ // pode remover esses logs depois
+ console.groupCollapsed('[Upgrade] fetchAll')
+ console.log('tenantId:', tid)
+ console.log('subscription:', subscription.value)
+ console.log('currentPlanKey:', currentPlanKey.value)
+ console.groupEnd()
} catch (e) {
console.error(e)
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
@@ -157,7 +215,7 @@ async function fetchAll() {
}
}
-async function changePlan(targetPlanId) {
+async function changePlan (targetPlanId) {
if (!subscription.value?.id) {
toast.add({
severity: 'warn',
@@ -187,17 +245,32 @@ async function changePlan(targetPlanId) {
// atualiza estado local
subscription.value.plan_id = data?.plan_id || targetPlanId
+ subscription.value.plan_key = data?.plan_key || planKeyById(subscription.value.plan_id) || subscription.value.plan_key
// ✅ recarrega entitlements (sem reload)
+ // (importante pra refletir o plano imediatamente)
entitlementsStore.clear?.()
- await entitlementsStore.fetch(tid, { force: true })
+
+ // seu store tem loadForTenant no guard; se existir aqui também, use primeiro
+ if (typeof entitlementsStore.loadForTenant === 'function') {
+ await entitlementsStore.loadForTenant(tid, { force: true })
+ } else if (typeof entitlementsStore.fetch === 'function') {
+ await entitlementsStore.fetch(tid, { force: true })
+ }
toast.add({
severity: 'success',
summary: 'Plano atualizado',
- detail: `Agora você está no plano ${planKeyById(subscription.value.plan_id) || ''}`.trim(),
+ detail: `Agora você está no plano ${planKeyById(subscription.value.plan_id) || subscription.value.plan_key || ''}`.trim(),
life: 3000
})
+
+ // ✅ garante consistência (principalmente se RPC mexer em mais campos)
+ await fetchAll()
+
+ // ✅ dispara o guard novamente: se o usuário perdeu acesso a uma rota PRO,
+ // ele deve ser redirecionado automaticamente.
+ await revalidateCurrentRoute()
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 5000 })
} finally {
@@ -205,7 +278,11 @@ async function changePlan(targetPlanId) {
}
}
-onMounted(fetchAll)
+onMounted(async () => {
+ // ✅ garante que o tenant já foi carregado antes de buscar planos
+ if (!tenantStore.loaded) await tenantStore.loadSessionAndTenant()
+ await fetchAll()
+})
// se trocar tenant ativo, recarrega
watch(
@@ -393,4 +470,4 @@ watch(
Observação: alguns recursos PRO podem depender de configuração inicial (ex.: SMS exige provedor).
-
+
\ No newline at end of file
diff --git a/src/views/pages/patient/PatientDashboard.vue b/src/views/pages/portal/PortalDashboard.vue
similarity index 100%
rename from src/views/pages/patient/PatientDashboard.vue
rename to src/views/pages/portal/PortalDashboard.vue
diff --git a/src/views/pages/portal/agenda/MyAppointmentsPage.vue b/src/views/pages/portal/agenda/MyAppointmentsPage.vue
new file mode 100644
index 0000000..e44e41b
--- /dev/null
+++ b/src/views/pages/portal/agenda/MyAppointmentsPage.vue
@@ -0,0 +1,9 @@
+
+
+
My Appointments
+
+
+
+
\ No newline at end of file
diff --git a/src/views/pages/portal/agenda/NewAppointmentPage.vue b/src/views/pages/portal/agenda/NewAppointmentPage.vue
new file mode 100644
index 0000000..ee6abb6
--- /dev/null
+++ b/src/views/pages/portal/agenda/NewAppointmentPage.vue
@@ -0,0 +1,9 @@
+
+
+
Add New Appointments
+
+
+
+
\ No newline at end of file
diff --git a/src/views/pages/public/AcceptInvitePage.vue b/src/views/pages/public/AcceptInvitePage.vue
new file mode 100644
index 0000000..4b93ae8
--- /dev/null
+++ b/src/views/pages/public/AcceptInvitePage.vue
@@ -0,0 +1,270 @@
+
+
+
+
Aceitar convite
+
+ Vamos validar seu convite e ativar seu acesso ao tenant.
+
+
+
+ Processando convite…
+
+
+
+
+ ✅ Convite aceito com sucesso. Redirecionando…
+
+
+
+
+
+
Não foi possível aceitar o convite
+
{{ state.error }}
+
+
+
+
+
+
+
+
+
+ Se você recebeu um convite, confirme se está logado com o mesmo e-mail do convite.
+
+
+
+
+ Preparando…
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/views/pages/therapist/agenda/MyAppointmentsPage.vue b/src/views/pages/therapist/agenda/MyAppointmentsPage.vue
new file mode 100644
index 0000000..e44e41b
--- /dev/null
+++ b/src/views/pages/therapist/agenda/MyAppointmentsPage.vue
@@ -0,0 +1,9 @@
+
+
+
My Appointments
+
+
+
+
\ No newline at end of file
diff --git a/src/views/pages/therapist/agenda/NewAppointmentPage.vue b/src/views/pages/therapist/agenda/NewAppointmentPage.vue
new file mode 100644
index 0000000..ee6abb6
--- /dev/null
+++ b/src/views/pages/therapist/agenda/NewAppointmentPage.vue
@@ -0,0 +1,9 @@
+
+