+ Aqui você define de quanto em quanto os horários aparecem e em qual minuto eles alinham (ex.: :00 ou :30).
+ Ex.: sábado com passo 60 e offset 30 gera 08:30, 09:30, 10:30…
+
+
+
+
+
+
+
+
+
Ativo
+
Se desligado, o online não oferece horários nesse dia.
+
diff --git a/src/layout/configuracoes/ConfiguracoesClinicaPage.vue b/src/layout/configuracoes/ConfiguracoesClinicaPage.vue
new file mode 100644
index 0000000..e69de29
diff --git a/src/layout/configuracoes/ConfiguracoesContaPage.vue b/src/layout/configuracoes/ConfiguracoesContaPage.vue
new file mode 100644
index 0000000..e69de29
diff --git a/src/layout/configuracoes/ConfiguracoesIntakePage.vue b/src/layout/configuracoes/ConfiguracoesIntakePage.vue
new file mode 100644
index 0000000..e69de29
diff --git a/src/main.js b/src/main.js
index d974b9e..32a5aa9 100644
--- a/src/main.js
+++ b/src/main.js
@@ -1,27 +1,112 @@
-import { createApp } from 'vue';
-import App from './App.vue';
-import router from './router';
+import { createApp } from 'vue'
+import { createPinia } from 'pinia'
+import App from './App.vue'
+import router from '@/router'
+import { setOnSignedOut, initSession, listenAuthChanges, refreshSession } from '@/app/session'
-import Aura from '@primeuix/themes/aura';
-import PrimeVue from 'primevue/config';
-import ConfirmationService from 'primevue/confirmationservice';
-import ToastService from 'primevue/toastservice';
+import Aura from '@primeuix/themes/aura'
+import PrimeVue from 'primevue/config'
+import ConfirmationService from 'primevue/confirmationservice'
+import ToastService from 'primevue/toastservice'
-import '@/assets/tailwind.css';
-import '@/assets/styles.scss';
+import '@/assets/tailwind.css'
+import '@/assets/styles.scss'
-const app = createApp(App);
+import { supabase } from '@/lib/supabase/client'
-app.use(router);
-app.use(PrimeVue, {
+async function applyUserThemeEarly() {
+ try {
+ const { data } = await supabase.auth.getUser()
+ const user = data?.user
+ if (!user) return
+
+ const { data: settings, error } = await supabase
+ .from('user_settings')
+ .select('theme_mode')
+ .eq('user_id', user.id)
+ .maybeSingle()
+
+ if (error || !settings?.theme_mode) return
+
+ const isDark = settings.theme_mode === 'dark'
+
+ // o PrimeVue usa o selector .app-dark
+ const root = document.documentElement
+ root.classList.toggle('app-dark', isDark)
+
+ // opcional: marca em storage pra teu layout composable ler depois
+ localStorage.setItem('ui_theme_mode', settings.theme_mode)
+ } catch {}
+}
+
+setOnSignedOut(() => {
+ router.replace('/auth/login')
+})
+
+// ===== flags globais (debug/controle) =====
+window.__sessionRefreshing = false
+window.__fromVisibilityRefresh = false
+window.__appBootstrapped = false
+// ========================================
+
+// 🛟 ao voltar da aba: refresh leve (sem concorrência + com flag global)
+let refreshing = false
+let refreshTimer = null
+
+let lastVisibilityRefreshAt = 0
+
+document.addEventListener('visibilitychange', async () => {
+ if (document.visibilityState !== 'visible') return
+
+ const now = Date.now()
+
+ // evita martelar: no máximo 1 refresh a cada 10s
+ if (now - lastVisibilityRefreshAt < 10_000) return
+
+ // se já tem refresh em andamento, não entra
+ if (window.__sessionRefreshing) return
+
+ lastVisibilityRefreshAt = now
+ console.log('[VISIBILITY] Aba voltou -> refreshSession()')
+
+ try {
+ window.__sessionRefreshing = true
+ await refreshSession()
+ } finally {
+ window.__sessionRefreshing = false
+ }
+})
+
+
+async function bootstrap () {
+ await initSession({ initial: true })
+ listenAuthChanges()
+
+ await applyUserThemeEarly()
+
+ const app = createApp(App)
+
+ const pinia = createPinia()
+ app.use(pinia)
+
+ app.use(router)
+
+ // ✅ garante router pronto antes de montar
+ await router.isReady()
+
+ app.use(PrimeVue, {
theme: {
- preset: Aura,
- options: {
- darkModeSelector: '.app-dark'
- }
+ preset: Aura,
+ options: { darkModeSelector: '.app-dark' }
}
-});
-app.use(ToastService);
-app.use(ConfirmationService);
+ })
+ app.use(ToastService)
+ app.use(ConfirmationService)
-app.mount('#app');
+ app.mount('#app')
+
+ // ✅ marca boot completo
+ window.__appBootstrapped = true
+}
+
+bootstrap()
diff --git a/src/navigation/index.js b/src/navigation/index.js
new file mode 100644
index 0000000..fde1bad
--- /dev/null
+++ b/src/navigation/index.js
@@ -0,0 +1,54 @@
+// src/navigation/index.js
+import adminMenu from './menus/admin.menu'
+import therapistMenu from './menus/therapist.menu'
+import patientMenu from './menus/patient.menu'
+import sakaiDemoMenu from './menus/sakai.demo.menu'
+import saasMenu from './menus/saas.menu'
+
+import { useSaasHealthStore } from '@/stores/saasHealthStore'
+
+const MENUS = {
+ admin: adminMenu,
+ therapist: therapistMenu,
+ patient: patientMenu
+}
+
+// aceita export de menu como ARRAY ou como FUNÇÃO (ctx) => []
+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)
+
+ // ✅ badge dinâmica do Health (contador vem do store)
+ // ⚠️ 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)
+ const saas = typeof saasMenu === 'function'
+ ? saasMenu(sessionCtx, { 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
+ ]
+ }
+
+ return [
+ ...base,
+ ...(saas.length ? [{ separator: true }, ...saas] : [])
+ ]
+}
diff --git a/src/navigation/menus/admin.menu.js b/src/navigation/menus/admin.menu.js
new file mode 100644
index 0000000..dce0f6a
--- /dev/null
+++ b/src/navigation/menus/admin.menu.js
@@ -0,0 +1,76 @@
+export default [
+ {
+ label: 'Admin',
+ items: [
+ {
+ label: 'Dashboard',
+ icon: 'pi pi-fw pi-home',
+ to: '/admin'
+ },
+ {
+ 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: '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"
+ {
+ label: 'Segurança',
+ icon: 'pi pi-fw pi-shield',
+ to: '/admin/configuracoes/seguranca'
+ }
+ ]
+ },
+ {
+ 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'
+ }
+ ]
+ }
+]
diff --git a/src/navigation/menus/patient.menu.js b/src/navigation/menus/patient.menu.js
new file mode 100644
index 0000000..974984c
--- /dev/null
+++ b/src/navigation/menus/patient.menu.js
@@ -0,0 +1,59 @@
+export default [
+ {
+ label: 'Paciente',
+ items: [
+ // ======================
+ // ✅ 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: '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)
+ // =====================================================
+ // A lógica do AppMenuItem que ajustamos suporta:
+ // - feature: 'chave_da_feature'
+ // - proBadge: true -> aparece "PRO" quando bloqueado
+ //
+ // ⚠️ Só descomente quando a rota existir.
+ //
+ // 1) Página pública de agendamento (se você criar um “link do paciente”)
+ // {
+ // label: 'Agendar online',
+ // icon: 'pi pi-fw pi-globe',
+ // to: '/patient/online-scheduling',
+ // feature: 'online_scheduling.public',
+ // proBadge: true
+ // },
+ //
+ // 2) Documentos/Arquivos (muito comum em SaaS clínico)
+ // {
+ // label: 'Documentos',
+ // icon: 'pi pi-fw pi-file',
+ // to: '/patient/documents',
+ // feature: 'patient_documents',
+ // proBadge: true
+ // },
+ //
+ // 3) Teleatendimento / Sala (se for ter)
+ // {
+ // label: 'Sala de atendimento',
+ // icon: 'pi pi-fw pi-video',
+ // to: '/patient/telehealth',
+ // feature: 'telehealth',
+ // proBadge: true
+ // }
+ ]
+ }
+]
diff --git a/src/navigation/menus/saas.menu.js b/src/navigation/menus/saas.menu.js
new file mode 100644
index 0000000..17b95d9
--- /dev/null
+++ b/src/navigation/menus/saas.menu.js
@@ -0,0 +1,58 @@
+// src/navigation/menus/saas.menu.js
+
+export default function saasMenu (authStore, opts = {}) {
+ if (!authStore?.isSaasAdmin) return []
+
+ const mismatchCount = Number(opts?.mismatchCount || 0)
+
+ return [
+ {
+ label: 'SaaS',
+ icon: 'pi pi-building',
+ path: '/saas', // ✅ necessário p/ expandir e controlar activePath
+ items: [
+ { label: 'Dashboard', icon: 'pi pi-chart-bar', to: '/saas' },
+
+ {
+ label: 'Planos',
+ icon: 'pi pi-star',
+ path: '/plans', // ✅ vira /saas/plans pelo parentPath
+ items: [
+ { label: 'Listagem de Planos', icon: 'pi pi-list', to: '/saas/plans' },
+
+ // ✅ NOVO: 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' },
+ { label: 'Controle de Recursos', icon: 'pi pi-th-large', to: '/saas/plan-features' }
+ ]
+ },
+
+ {
+ label: 'Assinaturas',
+ icon: 'pi pi-credit-card',
+ path: '/subscriptions', // ✅ vira /saas/subscriptions
+ items: [
+ { label: 'Listagem de Assinaturas', icon: 'pi pi-list', to: '/saas/subscriptions' },
+ { label: 'Histórico', icon: 'pi pi-history', to: '/saas/subscription-events' },
+ {
+ label: 'Saúde das Assinaturas',
+ icon: 'pi pi-shield',
+ to: '/saas/subscription-health',
+ ...(mismatchCount > 0
+ ? { badge: String(mismatchCount), badgeClass: 'p-badge p-badge-danger' }
+ : {})
+ }
+ ]
+ },
+ {
+ label: 'Intenções de Assinatura',
+ icon: 'pi pi-inbox',
+ to: '/saas/subscription-intents'
+ },
+
+ { label: 'Clínicas (Tenants)', icon: 'pi pi-users', to: '/saas/tenants' }
+ ]
+ }
+ ]
+}
diff --git a/src/navigation/menus/sakai.demo.menu.js b/src/navigation/menus/sakai.demo.menu.js
new file mode 100644
index 0000000..074e16a
--- /dev/null
+++ b/src/navigation/menus/sakai.demo.menu.js
@@ -0,0 +1,117 @@
+export default [
+ {
+ label: 'Home',
+ items: [{ label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/' }]
+ },
+ {
+ label: 'UI Components',
+ path: '/uikit',
+ items: [
+ { label: 'Form Layout', icon: 'pi pi-fw pi-id-card', to: '/demo/uikit/formlayout' },
+ { label: 'Input', icon: 'pi pi-fw pi-check-square', to: '/demo/uikit/input' },
+ { label: 'Button', icon: 'pi pi-fw pi-mobile', to: '/demo/uikit/button', class: 'rotated-icon' },
+ { label: 'Table', icon: 'pi pi-fw pi-table', to: '/demo/uikit/table' },
+ { label: 'List', icon: 'pi pi-fw pi-list', to: '/demo/uikit/list' },
+ { label: 'Tree', icon: 'pi pi-fw pi-share-alt', to: '/demo/uikit/tree' },
+ { label: 'Panel', icon: 'pi pi-fw pi-tablet', to: '/demo/uikit/panel' },
+ { label: 'Overlay', icon: 'pi pi-fw pi-clone', to: '/demo/uikit/overlay' },
+ { label: 'Media', icon: 'pi pi-fw pi-image', to: '/demo/uikit/media' },
+ { label: 'Menu', icon: 'pi pi-fw pi-bars', to: '/demo/uikit/menu' },
+ { label: 'Message', icon: 'pi pi-fw pi-comment', to: '/demo/uikit/message' },
+ { label: 'File', icon: 'pi pi-fw pi-file', to: '/demo/uikit/file' },
+ { label: 'Chart', icon: 'pi pi-fw pi-chart-bar', to: '/demo/uikit/charts' },
+ { label: 'Timeline', icon: 'pi pi-fw pi-calendar', to: '/demo/uikit/timeline' },
+ { label: 'Misc', icon: 'pi pi-fw pi-circle', to: '/demo/uikit/misc' }
+ ]
+ },
+ {
+ label: 'Prime Blocks',
+ icon: 'pi pi-fw pi-prime',
+ path: '/blocks',
+ items: [
+ { label: 'Free Blocks', icon: 'pi pi-fw pi-eye', to: '/utilities' },
+ { label: 'All Blocks', icon: 'pi pi-fw pi-globe', url: 'https://blocks.primevue.org/', target: '_blank' }
+ ]
+ },
+ {
+ label: 'Pages',
+ icon: 'pi pi-fw pi-briefcase',
+ path: '/pages',
+ items: [
+ { label: 'Landing', icon: 'pi pi-fw pi-globe', to: '/landing' },
+ {
+ label: 'Auth',
+ icon: 'pi pi-fw pi-user',
+ path: '/auth',
+ items: [
+ { label: 'Login', icon: 'pi pi-fw pi-sign-in', to: '/auth/login' },
+ { label: 'Error', icon: 'pi pi-fw pi-times-circle', to: '/auth/error' },
+ { label: 'Access Denied', icon: 'pi pi-fw pi-lock', to: '/auth/access' }
+ ]
+ },
+ { label: 'Crud', icon: 'pi pi-fw pi-pencil', to: '/pages/crud' },
+ { label: 'Not Found', icon: 'pi pi-fw pi-exclamation-circle', to: '/pages/notfound' },
+ { label: 'Empty', icon: 'pi pi-fw pi-circle-off', to: '/pages/empty' }
+ ]
+ },
+ {
+ label: 'Hierarchy',
+ icon: 'pi pi-fw pi-align-left',
+ path: '/hierarchy',
+ items: [
+ {
+ label: 'Submenu 1',
+ icon: 'pi pi-fw pi-align-left',
+ path: '/submenu_1',
+ items: [
+ {
+ label: 'Submenu 1.1',
+ icon: 'pi pi-fw pi-align-left',
+ path: '/submenu_1_1',
+ items: [
+ { label: 'Submenu 1.1.1', icon: 'pi pi-fw pi-align-left' },
+ { label: 'Submenu 1.1.2', icon: 'pi pi-fw pi-align-left' },
+ { label: 'Submenu 1.1.3', icon: 'pi pi-fw pi-align-left' }
+ ]
+ },
+ {
+ label: 'Submenu 1.2',
+ icon: 'pi pi-fw pi-align-left',
+ path: '/submenu_1_2',
+ items: [{ label: 'Submenu 1.2.1', icon: 'pi pi-fw pi-align-left' }]
+ }
+ ]
+ },
+ {
+ label: 'Submenu 2',
+ icon: 'pi pi-fw pi-align-left',
+ path: '/submenu_2',
+ items: [
+ {
+ label: 'Submenu 2.1',
+ icon: 'pi pi-fw pi-align-left',
+ path: '/submenu_2_1',
+ items: [
+ { label: 'Submenu 2.1.1', icon: 'pi pi-fw pi-align-left' },
+ { label: 'Submenu 2.1.2', icon: 'pi pi-fw pi-align-left' }
+ ]
+ },
+ {
+ label: 'Submenu 2.2',
+ icon: 'pi pi-fw pi-align-left',
+ path: '/submenu_2_2',
+ items: [{ label: 'Submenu 2.2.1', icon: 'pi pi-fw pi-align-left' }]
+ }
+ ]
+ }
+ ]
+ },
+ {
+ label: 'Get Started',
+ path: '/start',
+ items: [
+ { label: 'Documentation', icon: 'pi pi-fw pi-book', to: '/pages' },
+ { label: 'View Source', icon: 'pi pi-fw pi-github', url: 'https://github.com/primefaces/sakai-vue', target: '_blank' }
+ ]
+ }
+]
diff --git a/src/navigation/menus/therapist.menu.js b/src/navigation/menus/therapist.menu.js
new file mode 100644
index 0000000..6e57b94
--- /dev/null
+++ b/src/navigation/menus/therapist.menu.js
@@ -0,0 +1,20 @@
+export default [
+ {
+ label: 'Terapeuta',
+ items: [
+ { label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/therapist' },
+ { label: 'Agenda', icon: 'pi pi-fw pi-calendar', to: '/therapist/agenda' },
+
+ // ✅ PRO
+ {
+ label: 'Agendamento online',
+ 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' }
+ ]
+ }
+]
diff --git a/src/router/guards.js b/src/router/guards.js
new file mode 100644
index 0000000..9790796
--- /dev/null
+++ b/src/router/guards.js
@@ -0,0 +1,248 @@
+// ⚠️ Guard depende de sessão estável.
+// Nunca disparar refresh concorrente durante navegação protegida.
+// Ver comentário em session.js sobre race condition.
+
+import { supabase } from '@/lib/supabase/client'
+import { useTenantStore } from '@/stores/tenantStore'
+import { useEntitlementsStore } from '@/stores/entitlementsStore'
+import { buildUpgradeUrl } from '@/utils/upgradeContext'
+
+import { sessionUser, sessionReady, sessionRefreshing, initSession } from '@/app/session'
+
+// cache simples (evita bater no banco em toda navegação)
+let sessionUidCache = null
+
+// cache de saas admin por uid (pra não consultar tabela toda vez)
+let saasAdminCacheUid = null
+let saasAdminCacheIsAdmin = null
+
+function roleToPath (role) {
+ if (role === 'tenant_admin') return '/admin'
+ if (role === 'therapist') return '/therapist'
+ if (role === 'patient') return '/patient'
+ if (role === 'saas_admin') return '/saas'
+ return '/'
+}
+
+function sleep (ms) {
+ return new Promise(resolve => setTimeout(resolve, ms))
+}
+
+async function waitSessionIfRefreshing () {
+ if (!sessionReady.value) {
+ try { await initSession({ initial: true }) } catch (e) {
+ console.warn('[guards] initSession falhou:', e)
+ }
+ }
+
+ for (let i = 0; i < 30; i++) {
+ if (!sessionRefreshing.value) return
+ await sleep(50)
+ }
+}
+
+async function isSaasAdmin (uid) {
+ if (!uid) return false
+ if (saasAdminCacheUid === uid && typeof saasAdminCacheIsAdmin === 'boolean') {
+ return saasAdminCacheIsAdmin
+ }
+
+ const { data, error } = await supabase
+ .from('saas_admins')
+ .select('user_id')
+ .eq('user_id', uid)
+ .maybeSingle()
+
+ const ok = !error && !!data
+ saasAdminCacheUid = uid
+ saasAdminCacheIsAdmin = ok
+ return ok
+}
+
+// heurística segura: carrega entitlements se ainda não carregou ou mudou tenant
+function shouldLoadEntitlements (ent, tenantId) {
+ if (!tenantId) return false
+
+ const loaded = typeof ent.loaded === 'boolean' ? ent.loaded : false
+ const entTenantId = ent.activeTenantId ?? ent.tenantId ?? null
+
+ if (!loaded) return true
+ if (entTenantId && entTenantId !== tenantId) return true
+ return false
+}
+
+// wrapper: chama loadForTenant sem depender de force:false existir
+async function loadEntitlementsSafe (ent, tenantId, force) {
+ if (!ent?.loadForTenant) return
+
+ try {
+ await ent.loadForTenant(tenantId, { force: !!force })
+ } catch (e) {
+ // se quebrou tentando force false (store não suporta), tenta força true uma vez
+ if (!force) {
+ console.warn('[guards] ent.loadForTenant(force:false) falhou, tentando force:true', e)
+ await ent.loadForTenant(tenantId, { force: true })
+ return
+ }
+ throw e
+ }
+}
+
+// util: roles guard (plural)
+function matchesRoles (roles, activeRole) {
+ if (!Array.isArray(roles) || !roles.length) return true
+ return roles.includes(activeRole)
+}
+
+export function applyGuards (router) {
+ if (window.__guardsBound) return
+ window.__guardsBound = true
+
+ router.beforeEach(async (to) => {
+ const tlabel = `[guard] ${to.fullPath}`
+ 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 }
+
+ // se rota não exige auth, libera
+ if (!to.meta?.requiresAuth) { console.timeEnd(tlabel); return true }
+
+ // não decide nada no meio do refresh do session.js
+ console.timeLog(tlabel, 'waitSessionIfRefreshing')
+ await waitSessionIfRefreshing()
+
+ // precisa estar logado (fonte estável do session.js)
+ const uid = sessionUser.value?.id || null
+ if (!uid) {
+ sessionStorage.setItem('redirect_after_login', to.fullPath)
+ console.timeEnd(tlabel)
+ return { path: '/auth/login' }
+ }
+
+ // se uid mudou, invalida caches e stores dependentes
+ if (sessionUidCache !== uid) {
+ sessionUidCache = uid
+ saasAdminCacheUid = null
+ saasAdminCacheIsAdmin = null
+
+ const ent0 = useEntitlementsStore()
+ if (typeof ent0.invalidate === 'function') ent0.invalidate()
+ }
+
+ // saas admin (com cache)
+ if (to.meta?.saasAdmin) {
+ console.timeLog(tlabel, 'isSaasAdmin')
+ const ok = await isSaasAdmin(uid)
+ if (!ok) { console.timeEnd(tlabel); return { path: '/pages/access' } }
+ }
+
+ // carrega tenant + role
+ const tenant = useTenantStore()
+ console.timeLog(tlabel, 'tenant.loadSessionAndTenant?')
+ if (!tenant.loaded && !tenant.loading) {
+ await tenant.loadSessionAndTenant()
+ }
+
+ // se não tem user no store, trata como não logado
+ if (!tenant.user) {
+ sessionStorage.setItem('redirect_after_login', to.fullPath)
+ console.timeEnd(tlabel)
+ return { path: '/auth/login' }
+ }
+
+ // se não tem tenant ativo:
+ // - se não tem memberships active -> manda pro access (sem clínica)
+ // - se tem memberships active mas activeTenantId está null -> seta e segue
+ if (!tenant.activeTenantId) {
+ const mem = Array.isArray(tenant.memberships) ? tenant.memberships : []
+ const firstActive = mem.find(m => m && m.status === 'active' && m.tenant_id)
+
+ if (!firstActive) {
+ if (to.path === '/pages/access') { console.timeEnd(tlabel); return true }
+ console.timeEnd(tlabel)
+ return { path: '/pages/access' }
+ }
+
+ if (typeof tenant.setActiveTenant === 'function') {
+ tenant.setActiveTenant(firstActive.tenant_id)
+ } else {
+ tenant.activeTenantId = firstActive.tenant_id
+ tenant.activeRole = firstActive.role
+ }
+ }
+
+ const tenantId = tenant.activeTenantId
+ if (!tenantId) {
+ if (to.path === '/pages/access') { console.timeEnd(tlabel); return true }
+ console.timeEnd(tlabel)
+ return { path: '/pages/access' }
+ }
+
+ // entitlements (✅ carrega só quando precisa)
+ const ent = useEntitlementsStore()
+ if (shouldLoadEntitlements(ent, tenantId)) {
+ console.timeLog(tlabel, 'ent.loadForTenant')
+ await loadEntitlementsSafe(ent, tenantId, true)
+ }
+
+ // roles guard (plural)
+ const allowedRoles = to.meta?.roles
+ if (Array.isArray(allowedRoles) && allowedRoles.length) {
+ if (!matchesRoles(allowedRoles, tenant.activeRole)) {
+ const fallback = roleToPath(tenant.activeRole)
+ if (to.path === fallback) { console.timeEnd(tlabel); return { path: '/pages/access' } }
+ console.timeEnd(tlabel)
+ return { path: fallback }
+ }
+ }
+
+ // role guard (singular) - mantém compatibilidade
+ const requiredRole = to.meta?.role
+ if (requiredRole && tenant.activeRole !== requiredRole) {
+ const fallback = roleToPath(tenant.activeRole)
+ if (to.path === fallback) { console.timeEnd(tlabel); return { path: '/pages/access' } }
+ console.timeEnd(tlabel)
+ return { path: fallback }
+ }
+
+ // feature guard
+ const requiredFeature = to.meta?.feature
+ 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 }
+ }
+
+ console.timeEnd(tlabel)
+ return true
+ } catch (e) {
+ console.error('[guards] erro no beforeEach:', e)
+
+ // fallback seguro
+ if (to.meta?.public || to.path.startsWith('/auth')) return true
+ if (to.path === '/pages/access') return true
+
+ sessionStorage.setItem('redirect_after_login', to.fullPath)
+ return { path: '/auth/login' }
+ }
+ })
+
+ // auth listener (reset caches)
+ if (!window.__supabaseAuthListenerBound) {
+ window.__supabaseAuthListenerBound = true
+
+ supabase.auth.onAuthStateChange(() => {
+ sessionUidCache = null
+ saasAdminCacheUid = null
+ saasAdminCacheIsAdmin = null
+ })
+ }
+}
diff --git a/src/router/index.js b/src/router/index.js
index 8d7e588..18920ce 100644
--- a/src/router/index.js
+++ b/src/router/index.js
@@ -1,146 +1,111 @@
-import AppLayout from '@/layout/AppLayout.vue';
-import { createRouter, createWebHistory } 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 { 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]),
+
+ { path: '/:pathMatch(.*)*', name: 'notfound', component: () => import('@/views/pages/NotFound.vue') }
+]
const router = createRouter({
- history: createWebHistory(),
- routes: [
- {
- path: '/',
- component: AppLayout,
- children: [
- {
- path: '/',
- name: 'dashboard',
- component: () => import('@/views/Dashboard.vue')
- },
- {
- path: '/uikit/formlayout',
- name: 'formlayout',
- component: () => import('@/views/uikit/FormLayout.vue')
- },
- {
- path: '/uikit/input',
- name: 'input',
- component: () => import('@/views/uikit/InputDoc.vue')
- },
- {
- path: '/uikit/button',
- name: 'button',
- component: () => import('@/views/uikit/ButtonDoc.vue')
- },
- {
- path: '/uikit/table',
- name: 'table',
- component: () => import('@/views/uikit/TableDoc.vue')
- },
- {
- path: '/uikit/list',
- name: 'list',
- component: () => import('@/views/uikit/ListDoc.vue')
- },
- {
- path: '/uikit/tree',
- name: 'tree',
- component: () => import('@/views/uikit/TreeDoc.vue')
- },
- {
- path: '/uikit/panel',
- name: 'panel',
- component: () => import('@/views/uikit/PanelsDoc.vue')
- },
+ history: createWebHistory(),
+ routes,
+ scrollBehavior(to, from, savedPosition) {
+ // volta/avançar do navegador mantém posição
+ if (savedPosition) return savedPosition
- {
- path: '/uikit/overlay',
- name: 'overlay',
- component: () => import('@/views/uikit/OverlayDoc.vue')
- },
- {
- path: '/uikit/media',
- name: 'media',
- component: () => import('@/views/uikit/MediaDoc.vue')
- },
- {
- path: '/uikit/message',
- name: 'message',
- component: () => import('@/views/uikit/MessagesDoc.vue')
- },
- {
- path: '/uikit/file',
- name: 'file',
- component: () => import('@/views/uikit/FileDoc.vue')
- },
- {
- path: '/uikit/menu',
- name: 'menu',
- component: () => import('@/views/uikit/MenuDoc.vue')
- },
- {
- path: '/uikit/charts',
- name: 'charts',
- component: () => import('@/views/uikit/ChartDoc.vue')
- },
- {
- path: '/uikit/misc',
- name: 'misc',
- component: () => import('@/views/uikit/MiscDoc.vue')
- },
- {
- path: '/uikit/timeline',
- name: 'timeline',
- component: () => import('@/views/uikit/TimelineDoc.vue')
- },
- {
- path: '/blocks/free',
- name: 'blocks',
- meta: {
- breadcrumb: ['Prime Blocks', 'Free Blocks']
- },
- component: () => import('@/views/utilities/Blocks.vue')
- },
- {
- path: '/pages/empty',
- name: 'empty',
- component: () => import('@/views/pages/Empty.vue')
- },
- {
- path: '/pages/crud',
- name: 'crud',
- component: () => import('@/views/pages/Crud.vue')
- },
- {
- path: '/start/documentation',
- name: 'documentation',
- component: () => import('@/views/pages/Documentation.vue')
- }
- ]
- },
- {
- path: '/landing',
- name: 'landing',
- component: () => import('@/views/pages/Landing.vue')
- },
- {
- path: '/pages/notfound',
- name: 'notfound',
- component: () => import('@/views/pages/NotFound.vue')
- },
+ // qualquer navegação normal NÃO altera o scroll
+ return false
+ }
+})
- {
- path: '/auth/login',
- name: 'login',
- component: () => import('@/views/pages/auth/Login.vue')
- },
- {
- path: '/auth/access',
- name: 'accessDenied',
- component: () => import('@/views/pages/auth/Access.vue')
- },
- {
- path: '/auth/error',
- name: 'error',
- component: () => import('@/views/pages/auth/Error.vue')
- }
- ]
-});
+/* 🔎 DEBUG: listar todas as rotas registradas */
+console.log(
+ '[ROUTES]',
+ router.getRoutes().map(r => r.path).sort()
+)
-export default router;
+// ===== DEBUG NAV + TRACE (remover depois) =====
+const _push = router.push.bind(router)
+router.push = async (loc) => {
+ console.log('[router.push]', loc)
+ console.trace('[push caller]')
+
+ 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)
+ }
+
+ return res
+}
+
+const _replace = router.replace.bind(router)
+router.replace = async (loc) => {
+ console.log('[router.replace]', loc)
+ console.trace('[replace caller]')
+
+ 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)
+ }
+
+ return res
+}
+
+router.onError((e) => console.error('[router.onError]', e))
+
+router.beforeEach((to, from) => {
+ 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)
+})
+// ===== /DEBUG NAV + TRACE =====
+
+// ✅ mantém seus guards, mas agora a landing tem meta.public
+applyGuards(router)
+
+export default router
diff --git a/src/router/router.configuracoes.js b/src/router/router.configuracoes.js
new file mode 100644
index 0000000..a2dbb24
--- /dev/null
+++ b/src/router/router.configuracoes.js
@@ -0,0 +1,36 @@
+// src/router/router.configuracoes.js
+import AppLayout from '@/layout/AppLayout.vue'
+
+const configuracoesRoutes = {
+ path: '/configuracoes',
+ component: AppLayout,
+
+ meta: {
+ requiresAuth: true,
+ roles: ['admin', 'tenant_admin', 'therapist']
+ },
+
+ children: [
+ {
+ path: '',
+ component: () => import('@/layout/ConfiguracoesPage.vue'),
+ redirect: { name: 'ConfiguracoesAgenda' },
+
+ children: [
+ {
+ path: 'agenda',
+ name: 'ConfiguracoesAgenda',
+ component: () => import('@/layout/configuracoes/ConfiguracoesAgendaPage.vue')
+ }
+
+ // Futuro:
+ // { path: 'clinica', name: 'ConfiguracoesClinica', component: () => import('@/layout/configuracoes/ConfiguracoesClinicaPage.vue') },
+ // { path: 'intake', name: 'ConfiguracoesIntake', component: () => import('@/layout/configuracoes/ConfiguracoesIntakePage.vue') },
+ // { path: 'conta', name: 'ConfiguracoesConta', component: () => import('@/layout/configuracoes/ConfiguracoesContaPage.vue') },
+ ]
+ }
+ ]
+}
+
+export default configuracoesRoutes
+
diff --git a/src/router/router.me.js b/src/router/router.me.js
new file mode 100644
index 0000000..a57f146
--- /dev/null
+++ b/src/router/router.me.js
@@ -0,0 +1,32 @@
+// 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.admin.js b/src/router/routes.admin.js
new file mode 100644
index 0000000..55a56aa
--- /dev/null
+++ b/src/router/routes.admin.js
@@ -0,0 +1,93 @@
+// src/router/routes.admin.js
+import AppLayout from '@/layout/AppLayout.vue'
+
+export default {
+ path: '/admin',
+ component: AppLayout,
+
+ meta: {
+ // 🔐 Tudo aqui dentro exige login
+ requiresAuth: true,
+
+ // 👤 Perfil de acesso
+ role: 'tenant_admin'
+ },
+
+ children: [
+ // DASHBOARD
+ {
+ path: '',
+ name: 'admin-dashboard',
+ component: () => import('@/views/pages/admin/AdminDashboard.vue')
+ },
+
+ // PACIENTES - LISTA
+ {
+ path: 'pacientes',
+ name: 'admin-pacientes',
+ component: () => import('@/views/pages/admin/pacientes/PatientsIndexPage.vue')
+ },
+
+ // PACIENTES - CADASTRO (NOVO / EDITAR)
+ {
+ path: 'pacientes/cadastro',
+ name: 'admin-pacientes-cadastro',
+ component: () => import('@/views/pages/admin/pacientes/cadastro/PatientsCadastroPage.vue')
+ },
+ {
+ path: 'pacientes/cadastro/:id',
+ name: 'admin-pacientes-cadastro-edit',
+ component: () => import('@/views/pages/admin/pacientes/cadastro/PatientsCadastroPage.vue'),
+ props: true
+ },
+
+ // GRUPOS DE PACIENTES ✅
+ {
+ path: 'pacientes/grupos',
+ name: 'admin-pacientes-grupos',
+ component: () => import('@/views/pages/admin/pacientes/grupos/GruposPacientesPage.vue')
+ },
+
+ // TAGS
+ {
+ path: 'pacientes/tags',
+ name: 'admin-pacientes-tags',
+ component: () => import('@/views/pages/admin/pacientes/tags/TagsPage.vue')
+ },
+
+ // LINK EXTERNO
+ {
+ path: 'pacientes/link-externo',
+ name: 'admin.pacientes.linkexterno',
+ component: () => import('@/views/pages/admin/pacientes/cadastro/PatientsExternalLinkPage.vue')
+ },
+
+ // CADASTROS RECEBIDOS
+ {
+ path: 'pacientes/cadastro/recebidos',
+ name: 'admin.pacientes.recebidos',
+ component: () => import('@/views/pages/admin/pacientes/cadastro/recebidos/CadastrosRecebidosPage.vue')
+ },
+
+ // 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',
+ component: () => import('@/views/pages/admin/OnlineSchedulingAdminPage.vue'),
+ meta: {
+ feature: 'online_scheduling.manage'
+ }
+ }
+ ]
+}
diff --git a/src/router/routes.auth.js b/src/router/routes.auth.js
new file mode 100644
index 0000000..3aad7e0
--- /dev/null
+++ b/src/router/routes.auth.js
@@ -0,0 +1,45 @@
+export default {
+ path: '/auth',
+ children: [
+ {
+ path: 'login',
+ name: 'login',
+ component: () => import('@/views/pages/auth/Login.vue'),
+ meta: { public: true }
+ },
+
+ // ✅ Signup público, mas com URL /auth/signup
+ {
+ path: 'signup',
+ name: 'signup',
+ component: () => import('@/views/pages/public/Signup.vue'),
+ meta: { public: true }
+ },
+ {
+ path: 'welcome',
+ name: 'auth.welcome',
+ component: () => import('@/views/pages/auth/Welcome.vue'),
+ meta: { public: true }
+ },
+ {
+ path: 'reset-password',
+ name: 'resetPassword',
+ component: () => import('@/views/pages/auth/ResetPasswordPage.vue'),
+ meta: { public: true }
+ },
+
+ {
+ path: 'access',
+ name: 'accessDenied',
+ component: () => import('@/views/pages/auth/Access.vue'),
+ meta: { public: true }
+ },
+
+ {
+ path: 'error',
+ name: 'error',
+ component: () => import('@/views/pages/auth/Error.vue'),
+ meta: { public: true }
+ }
+ ]
+}
diff --git a/src/router/routes.billing.js b/src/router/routes.billing.js
new file mode 100644
index 0000000..87c9508
--- /dev/null
+++ b/src/router/routes.billing.js
@@ -0,0 +1,15 @@
+// src/router/routes.billing.js
+import AppLayout from '@/layout/AppLayout.vue'
+
+export default {
+ path: '/upgrade',
+ component: AppLayout,
+ meta: { requiresAuth: true },
+ children: [
+ {
+ path: '',
+ name: 'upgrade',
+ component: () => import('@/views/pages/billing/UpgradePage.vue')
+ }
+ ]
+}
diff --git a/src/router/routes.demo.js b/src/router/routes.demo.js
new file mode 100644
index 0000000..ac071c4
--- /dev/null
+++ b/src/router/routes.demo.js
@@ -0,0 +1,29 @@
+import AppLayout from '@/layout/AppLayout.vue'
+
+export default {
+ // ✅ não use '/' aqui (conflita com HomeCards)
+ path: '/demo',
+ component: AppLayout,
+ meta: { requiresAuth: true, role: 'tenant_admin' },
+ children: [
+ { path: 'uikit/formlayout', name: 'uikit-formlayout', component: () => import('@/views/uikit/FormLayout.vue') },
+ { path: 'uikit/input', name: 'uikit-input', component: () => import('@/views/uikit/InputDoc.vue') },
+ { path: 'uikit/button', name: 'uikit-button', component: () => import('@/views/uikit/ButtonDoc.vue') },
+ { path: 'uikit/table', name: 'uikit-table', component: () => import('@/views/uikit/TableDoc.vue') },
+ { path: 'uikit/list', name: 'uikit-list', component: () => import('@/views/uikit/ListDoc.vue') },
+ { path: 'uikit/tree', name: 'uikit-tree', component: () => import('@/views/uikit/TreeDoc.vue') },
+ { path: 'uikit/panel', name: 'uikit-panel', component: () => import('@/views/uikit/PanelsDoc.vue') },
+ { path: 'uikit/overlay', name: 'uikit-overlay', component: () => import('@/views/uikit/OverlayDoc.vue') },
+ { path: 'uikit/media', name: 'uikit-media', component: () => import('@/views/uikit/MediaDoc.vue') },
+ { path: 'uikit/menu', name: 'uikit-menu', component: () => import('@/views/uikit/MenuDoc.vue') },
+ { path: 'uikit/message', name: 'uikit-message', component: () => import('@/views/uikit/MessagesDoc.vue') },
+ { path: 'uikit/file', name: 'uikit-file', component: () => import('@/views/uikit/FileDoc.vue') },
+ { path: 'uikit/charts', name: 'uikit-charts', component: () => import('@/views/uikit/ChartDoc.vue') },
+ { path: 'uikit/timeline', name: 'uikit-timeline', component: () => import('@/views/uikit/TimelineDoc.vue') },
+ { path: 'uikit/misc', name: 'uikit-misc', component: () => import('@/views/uikit/MiscDoc.vue') },
+ { path: 'utilities', name: 'blocks', component: () => import('@/views/utilities/Blocks.vue') },
+ { path: 'pages', name: 'start-documentation', component: () => import('@/views/pages/Documentation.vue') },
+ { path: 'pages/empty', name: 'pages-empty', component: () => import('@/views/pages/Empty.vue') },
+ { path: 'pages/crud', name: 'pages-crud', component: () => import('@/views/pages/Crud.vue') }
+ ]
+}
diff --git a/src/router/routes.misc.js b/src/router/routes.misc.js
new file mode 100644
index 0000000..cc61170
--- /dev/null
+++ b/src/router/routes.misc.js
@@ -0,0 +1,15 @@
+export default {
+ path: '/',
+ children: [
+ {
+ path: 'landing',
+ name: 'landing',
+ component: () => import('@/views/pages/Landing.vue')
+ },
+ {
+ path: 'pages/notfound',
+ name: 'notfound',
+ component: () => import('@/views/pages/NotFound.vue')
+ }
+ ]
+}
diff --git a/src/router/routes.patient.js b/src/router/routes.patient.js
new file mode 100644
index 0000000..cf49ccd
--- /dev/null
+++ b/src/router/routes.patient.js
@@ -0,0 +1,19 @@
+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.public.js b/src/router/routes.public.js
new file mode 100644
index 0000000..9dd5142
--- /dev/null
+++ b/src/router/routes.public.js
@@ -0,0 +1,26 @@
+export default {
+ path: '/',
+ children: [
+ {
+ path: '',
+ name: 'home',
+ component: () => import('@/views/pages/HomeCards.vue')
+ },
+
+ // ✅ LP (página separada da landing do template)
+ {
+ path: 'lp',
+ name: 'lp',
+ component: () => import('@/views/pages/public/landingpage-v1.vue'),
+ meta: { public: true }
+ },
+
+ // ✅ cadastro externo
+ {
+ path: 'cadastro/paciente',
+ name: 'public.patient.intake',
+ component: () => import('@/views/pages/public/CadastroPacienteExterno.vue'),
+ meta: { public: true }
+ }
+ ]
+}
diff --git a/src/router/routes.saas.js b/src/router/routes.saas.js
new file mode 100644
index 0000000..a5d594a
--- /dev/null
+++ b/src/router/routes.saas.js
@@ -0,0 +1,60 @@
+import AppLayout from '@/layout/AppLayout.vue'
+
+export default {
+ path: '/saas',
+ component: AppLayout,
+ meta: { requiresAuth: true, saasAdmin: true },
+ children: [
+ {
+ path: '',
+ name: 'saas-dashboard',
+ component: () => import('@/views/pages/saas/SaasDashboard.vue')
+ },
+ {
+ path: 'plans',
+ name: 'saas-plans',
+ component: () => import('@/views/pages/saas/SaasPlansPage.vue')
+ },
+ {
+ path: 'plans-public',
+ name: 'saas-plans-public',
+ component: () => import('@/views/pages/saas/SaasPlansPublicPage.vue')
+ },
+ {
+ path: 'features',
+ name: 'saas-features',
+ component: () => import('@/views/pages/saas/SaasFeaturesPage.vue')
+ },
+ {
+ path: 'plan-features',
+ name: 'saas-plan-features',
+ component: () => import('@/views/pages/saas/SaasPlanFeaturesMatrixPage.vue')
+ },
+ {
+ path: 'subscriptions',
+ name: 'saas-subscriptions',
+ component: () => import('@/views/pages/saas/SaasSubscriptionsPage.vue')
+ },
+ {
+ path: 'subscription-events',
+ name: 'saas-subscription-events',
+ component: () => import('@/views/pages/saas/SaasSubscriptionEventsPage.vue')
+ },
+ {
+ path: 'subscription-health',
+ name: 'saas-subscription-health',
+ component: () => import('@/views/pages/saas/SaasSubscriptionHealthPage.vue')
+ },
+ {
+ path: 'subscription-intents',
+ name: 'saas.subscriptionIntents',
+ component: () => import('@/views/pages/saas/SubscriptionIntentsPage.vue'),
+ meta: { requiresAuth: true, saasAdmin: true }
+ },
+ {
+ path: 'tenants',
+ name: 'saas-tenants',
+ component: () => import('@/views/pages/saas/SaasPlaceholder.vue')
+ }
+ ]
+}
diff --git a/src/router/routes.therapist.js b/src/router/routes.therapist.js
new file mode 100644
index 0000000..9352e7f
--- /dev/null
+++ b/src/router/routes.therapist.js
@@ -0,0 +1,71 @@
+import AppLayout from '@/layout/AppLayout.vue'
+
+export default {
+ path: '/therapist',
+ component: AppLayout,
+
+ meta: {
+ // 🔐 Tudo aqui dentro exige login
+ requiresAuth: true,
+
+ // 👤 Perfil de acesso (seu guard atual usa meta.role)
+ role: 'therapist'
+ },
+
+ children: [
+ // ======================
+ // ✅ Dashboard Therapist
+ // ======================
+ {
+ path: '',
+ name: 'therapist-dashboard',
+ component: () => import('@/views/pages/therapist/TherapistDashboard.vue')
+ // herda requiresAuth + role do pai
+ },
+
+ // ======================
+ // ✅ Segurança
+ // ======================
+ {
+ path: 'settings/security',
+ name: 'therapist-settings-security',
+ component: () => import('@/views/pages/auth/SecurityPage.vue')
+ // herda requiresAuth + role do pai
+ },
+
+ // ==========================================
+ // 🔒 PRO — Online Scheduling (gestão interna)
+ // ==========================================
+ // feature gate via meta.feature:
+ // - bloqueia rota (guard)
+ // - menu pode desabilitar/ocultar (entitlementsStore.has)
+ {
+ path: 'online-scheduling',
+ 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.
+ // {
+ // path: 'online-scheduling/public',
+ // name: 'therapist-online-scheduling-public',
+ // component: () => import('@/views/pages/therapist/OnlineSchedulingPublicPage.vue'),
+ // meta: { feature: 'online_scheduling.public' }
+ // }
+ ]
+}
diff --git a/src/service/CountryService.js b/src/services/CountryService.js
old mode 100755
new mode 100644
similarity index 100%
rename from src/service/CountryService.js
rename to src/services/CountryService.js
diff --git a/src/service/CustomerService.js b/src/services/CustomerService.js
old mode 100755
new mode 100644
similarity index 100%
rename from src/service/CustomerService.js
rename to src/services/CustomerService.js
diff --git a/src/services/GruposPacientes.service.js b/src/services/GruposPacientes.service.js
new file mode 100644
index 0000000..fa2f931
--- /dev/null
+++ b/src/services/GruposPacientes.service.js
@@ -0,0 +1,173 @@
+// src/services/patientGroups.js
+import { supabase } from '@/lib/supabase/client'
+
+function pickCount (row) {
+ return row?.patients_count ?? row?.patient_count ?? 0
+}
+
+async function getOwnerId () {
+ 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.')
+ return uid
+}
+
+function normalizeNome (s) {
+ return String(s || '').trim().toLowerCase().replace(/\s+/g, ' ')
+}
+
+function isUniqueViolation (err) {
+ if (!err) return false
+ if (err.code === '23505') return true
+ const msg = String(err.message || '')
+ return /duplicate key value violates unique constraint/i.test(msg)
+}
+
+/**
+ * Lista grupos do usuário + grupos do sistema, já com contagem.
+ * Usa a view v_patient_groups_with_counts (preferencial).
+ * Fallback: tabela patient_groups + contagem pela pivot.
+ */
+export async function listGroupsWithCounts () {
+ const ownerId = await getOwnerId()
+
+ // 1) View (preferencial) — agora já é a fonte correta
+ const { data: vData, error: vErr } = await supabase
+ .from('v_patient_groups_with_counts')
+ .select('*')
+ .or(`owner_id.eq.${ownerId},is_system.eq.true`)
+ .order('nome', { ascending: true })
+
+ if (!vErr) {
+ return (vData || []).map(r => ({
+ ...r,
+ patients_count: pickCount(r)
+ }))
+ }
+
+ // 2) Fallback (caso view não exista / erro de schema)
+ const { data: groups, error: gErr } = await supabase
+ .from('patient_groups')
+ .select('id,nome,cor,is_system,is_active,owner_id,created_at,updated_at')
+ .or(`owner_id.eq.${ownerId},is_system.eq.true`)
+ .order('nome', { ascending: true })
+ if (gErr) throw gErr
+
+ const ids = (groups || []).map(g => g.id).filter(Boolean)
+ if (!ids.length) return []
+
+ // conta pacientes por grupo na pivot
+ const { data: rel, error: rErr } = await supabase
+ .from('patient_group_patient')
+ .select('patient_group_id')
+ .in('patient_group_id', ids)
+ if (rErr) throw rErr
+
+ const counts = new Map()
+ for (const row of rel || []) {
+ const gid = row.patient_group_id
+ if (!gid) continue
+ counts.set(gid, (counts.get(gid) || 0) + 1)
+ }
+
+ return (groups || []).map(g => ({
+ ...g,
+ patients_count: counts.get(g.id) || 0
+ }))
+}
+
+export async function createGroup (nome, cor = null) {
+ const ownerId = await getOwnerId()
+
+ const raw = String(nome || '').trim()
+ if (!raw) throw new Error('Nome do grupo é obrigatório.')
+
+ const nNorm = normalizeNome(raw)
+
+ // proteção extra no front: busca por igualdade "normalizada"
+ // (mantém RLS como autoridade final, mas evita UX ruim)
+ const { data: existing, error: exErr } = await supabase
+ .from('patient_groups')
+ .select('id,nome')
+ .eq('owner_id', ownerId)
+ .eq('is_system', false)
+ .limit(50)
+
+ if (!exErr && (existing || []).some(r => normalizeNome(r.nome) === nNorm)) {
+ throw new Error('Já existe um grupo com esse nome.')
+ }
+
+ const payload = {
+ owner_id: ownerId,
+ nome: raw,
+ cor: cor || null
+ }
+
+ const { data, error } = await supabase
+ .from('patient_groups')
+ .insert(payload)
+ .select('id,nome,cor,is_system,owner_id,is_active,created_at,updated_at')
+ .single()
+
+ if (error) {
+ if (isUniqueViolation(error)) throw new Error('Já existe um grupo com esse nome.')
+ throw error
+ }
+
+ return data
+}
+
+export async function updateGroup (id, nome, cor = null) {
+ const ownerId = await getOwnerId()
+
+ const raw = String(nome || '').trim()
+ if (!id) throw new Error('ID inválido.')
+ if (!raw) throw new Error('Nome do grupo é obrigatório.')
+
+ // (opcional) valida duplicidade entre os grupos do owner (não-system)
+ const nNorm = normalizeNome(raw)
+ const { data: existing, error: exErr } = await supabase
+ .from('patient_groups')
+ .select('id,nome')
+ .eq('owner_id', ownerId)
+ .eq('is_system', false)
+ .neq('id', id)
+ .limit(80)
+
+ if (!exErr && (existing || []).some(r => normalizeNome(r.nome) === nNorm)) {
+ throw new Error('Já existe um grupo com esse nome.')
+ }
+
+ const { data, error } = await supabase
+ .from('patient_groups')
+ .update({ nome: raw, cor: cor || null, updated_at: new Date().toISOString() })
+ .eq('id', id)
+ .eq('owner_id', ownerId)
+ .eq('is_system', false)
+ .select('id,nome,cor,is_system,owner_id,is_active,created_at,updated_at')
+ .single()
+
+ if (error) {
+ if (isUniqueViolation(error)) throw new Error('Já existe um grupo com esse nome.')
+ throw error
+ }
+
+ return data
+}
+
+export async function deleteGroup (id) {
+ const ownerId = await getOwnerId()
+
+ if (!id) throw new Error('ID inválido.')
+
+ const { error } = await supabase
+ .from('patient_groups')
+ .delete()
+ .eq('id', id)
+ .eq('owner_id', ownerId)
+ .eq('is_system', false)
+
+ if (error) throw error
+ return true
+}
diff --git a/src/service/NodeService.js b/src/services/NodeService.js
old mode 100755
new mode 100644
similarity index 100%
rename from src/service/NodeService.js
rename to src/services/NodeService.js
diff --git a/src/service/PhotoService.js b/src/services/PhotoService.js
old mode 100755
new mode 100644
similarity index 100%
rename from src/service/PhotoService.js
rename to src/services/PhotoService.js
diff --git a/src/service/ProductService.js b/src/services/ProductService.js
similarity index 100%
rename from src/service/ProductService.js
rename to src/services/ProductService.js
diff --git a/src/services/agendaConfigService.js b/src/services/agendaConfigService.js
new file mode 100644
index 0000000..622e78f
--- /dev/null
+++ b/src/services/agendaConfigService.js
@@ -0,0 +1,76 @@
+// src/services/agendaConfigService.js
+import { supabase } from '@/lib/supabase/client'
+
+export async function getOwnerId() {
+ 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.')
+ return uid
+}
+
+export async function fetchSlotsRegras(ownerId) {
+ const { data, error } = await supabase
+ .from('agenda_slots_regras')
+ .select('*')
+ .eq('owner_id', ownerId)
+ .order('dia_semana', { ascending: true })
+ if (error) throw error
+ return data || []
+}
+
+export async function upsertSlotRegra(ownerId, payload) {
+ const row = {
+ owner_id: ownerId,
+ dia_semana: Number(payload.dia_semana),
+ passo_minutos: Number(payload.passo_minutos),
+ offset_minutos: Number(payload.offset_minutos),
+ buffer_antes_min: Number(payload.buffer_antes_min || 0),
+ buffer_depois_min: Number(payload.buffer_depois_min || 0),
+ min_antecedencia_horas: Number(payload.min_antecedencia_horas || 0),
+ ativo: !!payload.ativo
+ }
+
+ const { data, error } = await supabase
+ .from('agenda_slots_regras')
+ .upsert(row, { onConflict: 'owner_id,dia_semana' })
+ .select('*')
+ .single()
+
+ if (error) throw error
+ return data
+}
+
+export function normalizeHHMM(v) {
+ if (v == null) return null
+ const s = String(v).trim()
+ if (/^\d{2}:\d{2}$/.test(s)) return s
+ if (/^\d{2}:\d{2}:\d{2}$/.test(s)) return s.slice(0, 5)
+ return s
+}
+
+export function ruleKey(r) {
+ return [
+ r.dia_semana,
+ normalizeHHMM(r.hora_inicio),
+ normalizeHHMM(r.hora_fim),
+ (r.modalidade || 'ambos'),
+ !!r.ativo
+ ].join('|')
+}
+
+/**
+ * Remove duplicados exatos antes de mandar pro banco.
+ * (DB já tem UNIQUE, mas isso evita erro e deixa UX melhor)
+ */
+export function dedupeRegrasSemanais(regras) {
+ const seen = new Set()
+ const out = []
+ for (const r of regras || []) {
+ const k = ruleKey(r)
+ if (seen.has(k)) continue
+ seen.add(k)
+ out.push(r)
+ }
+ return out
+}
diff --git a/src/services/agendaSlotsBloqueadosService.js b/src/services/agendaSlotsBloqueadosService.js
new file mode 100644
index 0000000..5e20a45
--- /dev/null
+++ b/src/services/agendaSlotsBloqueadosService.js
@@ -0,0 +1,45 @@
+// src/services/agendaSlotsBloqueadosService.js
+import { supabase } from '@/lib/supabase/client'
+
+export async function fetchSlotsBloqueados(ownerId, diaSemana) {
+ const { data, error } = await supabase
+ .from('agenda_slots_bloqueados_semanais')
+ .select('*')
+ .eq('owner_id', ownerId)
+ .eq('dia_semana', diaSemana)
+ .eq('ativo', true)
+ .order('hora_inicio', { ascending: true })
+
+ if (error) throw error
+ return data || []
+}
+
+export async function setSlotBloqueado(ownerId, diaSemana, horaInicio, isBloqueado, motivo = null) {
+ if (isBloqueado) {
+ const { error } = await supabase
+ .from('agenda_slots_bloqueados_semanais')
+ .upsert(
+ {
+ owner_id: ownerId,
+ dia_semana: diaSemana,
+ hora_inicio: horaInicio,
+ motivo: motivo || null,
+ ativo: true
+ },
+ { onConflict: 'owner_id,dia_semana,hora_inicio' }
+ )
+ if (error) throw error
+ return true
+ }
+
+ // “desbloquear”: deletar (ou marcar ativo=false; aqui vou deletar por simplicidade)
+ const { error } = await supabase
+ .from('agenda_slots_bloqueados_semanais')
+ .delete()
+ .eq('owner_id', ownerId)
+ .eq('dia_semana', diaSemana)
+ .eq('hora_inicio', horaInicio)
+
+ if (error) throw error
+ return true
+}
diff --git a/src/services/authService.js b/src/services/authService.js
new file mode 100644
index 0000000..e025522
--- /dev/null
+++ b/src/services/authService.js
@@ -0,0 +1,18 @@
+import { supabase } from '@/lib/supabase/client'
+
+export const signIn = async (email, password) => {
+ return await supabase.auth.signInWithPassword({ email, password })
+}
+
+export const signUp = async (email, password) => {
+ return await supabase.auth.signUp({ email, password })
+}
+
+export const signOut = async () => {
+ return await supabase.auth.signOut()
+}
+
+export const getUser = async () => {
+ const { data } = await supabase.auth.getUser()
+ return data.user
+}
diff --git a/src/services/patientTags.service.js b/src/services/patientTags.service.js
new file mode 100644
index 0000000..59b8507
--- /dev/null
+++ b/src/services/patientTags.service.js
@@ -0,0 +1,77 @@
+// src/services/patientTags.js
+import { supabase } from '@/lib/supabase/client'
+
+async function getOwnerId() {
+ const { data, error } = await supabase.auth.getUser()
+ if (error) throw error
+ const user = data?.user
+ if (!user) throw new Error('Você precisa estar logado.')
+ return user.id
+}
+
+export async function listTagsWithCounts() {
+ const ownerId = await getOwnerId()
+ const v = await supabase
+ .from('v_tag_patient_counts')
+ .select('*')
+ .eq('owner_id', ownerId)
+ .order('name', { ascending: true })
+
+ if (!v.error) return v.data || []
+
+ const t = await supabase
+ .from('patient_tags')
+ .select('id, owner_id, name, color, is_native, created_at, updated_at')
+ .eq('owner_id', ownerId)
+ .order('name', { ascending: true })
+
+ if (t.error) throw t.error
+ return (t.data || []).map(r => ({ ...r, patient_count: 0 }))
+}
+
+export async function createTag({ name, color = null }) {
+ const ownerId = await getOwnerId()
+ const { error } = await supabase.from('patient_tags').insert({ owner_id: ownerId, name, color })
+ if (error) throw error
+}
+
+export async function updateTag({ id, name, color = null }) {
+ const ownerId = await getOwnerId()
+ const { error } = await supabase
+ .from('patient_tags')
+ .update({ name, color, updated_at: new Date().toISOString() })
+ .eq('id', id)
+ .eq('owner_id', ownerId)
+ if (error) throw error
+}
+
+export async function deleteTagsByIds(ids = []) {
+ const ownerId = await getOwnerId()
+ if (!ids.length) return
+
+ const pivotDel = await supabase
+ .from('patient_patient_tag')
+ .delete()
+ .eq('owner_id', ownerId)
+ .in('tag_id', ids)
+ if (pivotDel.error) throw pivotDel.error
+
+ const tagDel = await supabase
+ .from('patient_tags')
+ .delete()
+ .eq('owner_id', ownerId)
+ .in('id', ids)
+ if (tagDel.error) throw tagDel.error
+}
+
+export async function fetchPatientsByTagId(tagId) {
+ const ownerId = await getOwnerId()
+ const { data, error } = await supabase
+ .from('patient_patient_tag')
+ .select('patient_id, patients:patients(id, name, email, phone)')
+ .eq('owner_id', ownerId)
+ .eq('tag_id', tagId)
+
+ if (error) throw error
+ return (data || []).map(r => r.patients).filter(Boolean)
+}
diff --git a/src/services/subscriptionIntents.js b/src/services/subscriptionIntents.js
new file mode 100644
index 0000000..c5f6a71
--- /dev/null
+++ b/src/services/subscriptionIntents.js
@@ -0,0 +1,63 @@
+// src/services/subscriptionIntents.js
+import { supabase } from '@/lib/supabase/client'
+
+function applyFilters(query, { q, status, planKey, interval }) {
+ if (q) query = query.ilike('email', `%${q}%`)
+ if (status) query = query.eq('status', status)
+ if (planKey) query = query.eq('plan_key', planKey)
+ if (interval) query = query.eq('interval', interval)
+ return query
+}
+
+export async function listSubscriptionIntents(filters = {}) {
+ let query = supabase
+ .from('subscription_intents')
+ .select('*')
+ .order('created_at', { ascending: false })
+
+ query = applyFilters(query, filters)
+
+ const { data, error } = await query
+ if (error) throw error
+ return data || []
+}
+
+export async function markIntentPaid(intentId, notes = '') {
+ // 1) marca como pago
+ const { data: updated, error: upErr } = await supabase
+ .from('subscription_intents')
+ .update({
+ status: 'paid',
+ paid_at: new Date().toISOString(),
+ notes: notes || null
+ })
+ .eq('id', intentId)
+ .select('*')
+ .maybeSingle()
+
+ if (upErr) throw upErr
+
+ // 2) ativa subscription do tenant (Modelo B)
+ const { data: sub, error: rpcErr } = await supabase.rpc('activate_subscription_from_intent', {
+ p_intent_id: intentId
+ })
+
+ if (rpcErr) throw rpcErr
+
+ return { intent: updated, subscription: sub }
+}
+
+export async function cancelIntent(intentId, notes = '') {
+ const { data, error } = await supabase
+ .from('subscription_intents')
+ .update({
+ status: 'canceled',
+ notes: notes || null
+ })
+ .eq('id', intentId)
+ .select('*')
+ .maybeSingle()
+
+ if (error) throw error
+ return data
+}
diff --git a/src/sql-arquivos/01_profiles.sql b/src/sql-arquivos/01_profiles.sql
new file mode 100644
index 0000000..ff1694d
--- /dev/null
+++ b/src/sql-arquivos/01_profiles.sql
@@ -0,0 +1,110 @@
+-- =========================================================
+-- Agência PSI — Profiles (v2) + Trigger + RLS
+-- - 1 profile por auth.users.id
+-- - role base (admin|therapist|patient)
+-- - pronto para evoluir p/ multi-tenant depois
+-- =========================================================
+
+-- 0) Função padrão updated_at (se já existir, mantém)
+create or replace function public.set_updated_at()
+returns trigger
+language plpgsql
+as $$
+begin
+ new.updated_at = now();
+ return new;
+end;
+$$;
+
+-- 1) Tabela profiles
+create table if not exists public.profiles (
+ id uuid primary key, -- = auth.users.id
+ email text,
+ full_name text,
+ avatar_url text,
+
+ role text not null default 'patient',
+ status text not null default 'active',
+
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now(),
+
+ constraint profiles_role_check check (role in ('admin','therapist','patient')),
+ constraint profiles_status_check check (status in ('active','inactive','invited'))
+);
+
+-- FK opcional (em Supabase costuma ser ok)
+do $$
+begin
+ if not exists (
+ select 1
+ from pg_constraint
+ where conname = 'profiles_id_fkey'
+ ) then
+ alter table public.profiles
+ add constraint profiles_id_fkey
+ foreign key (id) references auth.users(id)
+ on delete cascade;
+ end if;
+end $$;
+
+-- Índices úteis
+create index if not exists profiles_role_idx on public.profiles(role);
+create index if not exists profiles_status_idx on public.profiles(status);
+
+-- 2) Trigger updated_at
+drop trigger if exists t_profiles_set_updated_at on public.profiles;
+create trigger t_profiles_set_updated_at
+before update on public.profiles
+for each row execute function public.set_updated_at();
+
+-- 3) Trigger pós-signup: cria profile automático
+-- Observação: roda como SECURITY DEFINER
+create or replace function public.handle_new_user()
+returns trigger
+language plpgsql
+security definer
+set search_path = public
+as $$
+begin
+ insert into public.profiles (id, email, role, status)
+ values (new.id, new.email, 'patient', 'active')
+ on conflict (id) do update
+ set email = excluded.email;
+
+ return new;
+end;
+$$;
+
+drop trigger if exists on_auth_user_created on auth.users;
+create trigger on_auth_user_created
+after insert on auth.users
+for each row execute function public.handle_new_user();
+
+-- 4) RLS
+alter table public.profiles enable row level security;
+
+-- Leitura do próprio profile
+drop policy if exists "profiles_select_own" on public.profiles;
+create policy "profiles_select_own"
+on public.profiles
+for select
+to authenticated
+using (id = auth.uid());
+
+-- Update do próprio profile (campos não-sensíveis)
+drop policy if exists "profiles_update_own" on public.profiles;
+create policy "profiles_update_own"
+on public.profiles
+for update
+to authenticated
+using (id = auth.uid())
+with check (id = auth.uid());
+
+-- Insert só do próprio (na prática quem insere é trigger, mas deixa coerente)
+drop policy if exists "profiles_insert_own" on public.profiles;
+create policy "profiles_insert_own"
+on public.profiles
+for insert
+to authenticated
+with check (id = auth.uid());
diff --git a/src/sql-arquivos/supabase_cadastro_externo.sql b/src/sql-arquivos/supabase_cadastro_externo.sql
new file mode 100644
index 0000000..e9b170f
--- /dev/null
+++ b/src/sql-arquivos/supabase_cadastro_externo.sql
@@ -0,0 +1,212 @@
+-- =========================================================
+-- Agência PSI Quasar — Cadastro Externo de Paciente (Supabase/Postgres)
+-- Objetivo:
+-- - Ter um link público com TOKEN que o terapeuta envia ao paciente
+-- - Paciente preenche um formulário público
+-- - Salva em "intake requests" (pré-cadastro)
+-- - Terapeuta revisa e converte em paciente dentro do sistema
+--
+-- Tabelas:
+-- - patient_invites
+-- - patient_intake_requests
+--
+-- Funções:
+-- - create_patient_intake_request (RPC pública - anon)
+--
+-- Segurança:
+-- - RLS habilitada
+-- - Público (anon) não lê nada, só executa RPC
+-- - Terapeuta (authenticated) lê/atualiza somente seus registros
+-- =========================================================
+
+-- 0) Tabelas
+create table if not exists public.patient_invites (
+ id uuid primary key default gen_random_uuid(),
+ owner_id uuid not null,
+ token text not null unique,
+ active boolean not null default true,
+ expires_at timestamptz null,
+ max_uses int null,
+ uses int not null default 0,
+ created_at timestamptz not null default now()
+);
+
+create index if not exists patient_invites_owner_id_idx on public.patient_invites(owner_id);
+create index if not exists patient_invites_token_idx on public.patient_invites(token);
+
+create table if not exists public.patient_intake_requests (
+ id uuid primary key default gen_random_uuid(),
+ owner_id uuid not null,
+ token text not null,
+ name text not null,
+ email text null,
+ phone text null,
+ notes text null,
+ consent boolean not null default false,
+ status text not null default 'new', -- new | converted | rejected
+ created_at timestamptz not null default now()
+);
+
+create index if not exists patient_intake_owner_id_idx on public.patient_intake_requests(owner_id);
+create index if not exists patient_intake_token_idx on public.patient_intake_requests(token);
+create index if not exists patient_intake_status_idx on public.patient_intake_requests(status);
+
+-- 1) RLS
+alter table public.patient_invites enable row level security;
+alter table public.patient_intake_requests enable row level security;
+
+-- 2) Fechar acesso direto para anon (público)
+revoke all on table public.patient_invites from anon;
+revoke all on table public.patient_intake_requests from anon;
+
+-- 3) Policies: terapeuta (authenticated) - somente próprios registros
+
+-- patient_invites
+drop policy if exists invites_select_own on public.patient_invites;
+create policy invites_select_own
+on public.patient_invites for select
+to authenticated
+using (owner_id = auth.uid());
+
+drop policy if exists invites_insert_own on public.patient_invites;
+create policy invites_insert_own
+on public.patient_invites for insert
+to authenticated
+with check (owner_id = auth.uid());
+
+drop policy if exists invites_update_own on public.patient_invites;
+create policy invites_update_own
+on public.patient_invites for update
+to authenticated
+using (owner_id = auth.uid())
+with check (owner_id = auth.uid());
+
+-- patient_intake_requests
+drop policy if exists intake_select_own on public.patient_intake_requests;
+create policy intake_select_own
+on public.patient_intake_requests for select
+to authenticated
+using (owner_id = auth.uid());
+
+drop policy if exists intake_update_own on public.patient_intake_requests;
+create policy intake_update_own
+on public.patient_intake_requests for update
+to authenticated
+using (owner_id = auth.uid())
+with check (owner_id = auth.uid());
+
+-- 4) RPC pública para criar intake (página pública)
+-- Importantíssimo: security definer + search_path fixo
+create or replace function public.create_patient_intake_request(
+ p_token text,
+ p_name text,
+ p_email text default null,
+ p_phone text default null,
+ p_notes text default null,
+ p_consent boolean default false
+)
+returns uuid
+language plpgsql
+security definer
+set search_path = public
+as $$
+declare
+ v_owner uuid;
+ v_active boolean;
+ v_expires timestamptz;
+ v_max_uses int;
+ v_uses int;
+ v_id uuid;
+begin
+ select owner_id, active, expires_at, max_uses, uses
+ into v_owner, v_active, v_expires, v_max_uses, v_uses
+ from public.patient_invites
+ where token = p_token
+ limit 1;
+
+ if v_owner is null then
+ raise exception 'Token inválido';
+ end if;
+
+ if v_active is not true then
+ raise exception 'Link desativado';
+ end if;
+
+ if v_expires is not null and now() > v_expires then
+ raise exception 'Link expirado';
+ end if;
+
+ if v_max_uses is not null and v_uses >= v_max_uses then
+ raise exception 'Limite de uso atingido';
+ end if;
+
+ if p_name is null or length(trim(p_name)) = 0 then
+ raise exception 'Nome é obrigatório';
+ end if;
+
+ insert into public.patient_intake_requests
+ (owner_id, token, name, email, phone, notes, consent, status)
+ values
+ (v_owner, p_token, trim(p_name),
+ nullif(lower(trim(p_email)), ''),
+ nullif(trim(p_phone), ''),
+ nullif(trim(p_notes), ''),
+ coalesce(p_consent, false),
+ 'new')
+ returning id into v_id;
+
+ update public.patient_invites
+ set uses = uses + 1
+ where token = p_token;
+
+ return v_id;
+end;
+$$;
+
+grant execute on function public.create_patient_intake_request(text, text, text, text, text, boolean) to anon;
+grant execute on function public.create_patient_intake_request(text, text, text, text, text, boolean) to authenticated;
+
+-- 5) (Opcional) helper para rotacionar token no painel (somente authenticated)
+-- Você pode usar no front via supabase.rpc('rotate_patient_invite_token')
+create or replace function public.rotate_patient_invite_token(
+ p_new_token text
+)
+returns uuid
+language plpgsql
+security definer
+set search_path = public
+as $$
+declare
+ v_uid uuid;
+ v_id uuid;
+begin
+ -- pega o usuário logado
+ v_uid := auth.uid();
+ if v_uid is null then
+ raise exception 'Usuário não autenticado';
+ end if;
+
+ -- desativa tokens antigos ativos do usuário
+ update public.patient_invites
+ set active = false
+ where owner_id = v_uid
+ and active = true;
+
+ -- cria novo token
+ insert into public.patient_invites (owner_id, token, active)
+ values (v_uid, p_new_token, true)
+ returning id into v_id;
+
+ return v_id;
+end;
+$$;
+
+grant execute on function public.rotate_patient_invite_token(text) to authenticated;
+
+grant select, insert, update, delete on table public.patient_invites to authenticated;
+grant select, insert, update, delete on table public.patient_intake_requests to authenticated;
+
+-- anon não precisa acessar tabelas diretamente
+revoke all on table public.patient_invites from anon;
+revoke all on table public.patient_intake_requests from anon;
+
diff --git a/src/sql-arquivos/supabase_cadastro_pacientes.sql b/src/sql-arquivos/supabase_cadastro_pacientes.sql
new file mode 100644
index 0000000..4eb0d54
--- /dev/null
+++ b/src/sql-arquivos/supabase_cadastro_pacientes.sql
@@ -0,0 +1,266 @@
+-- =========================================================
+-- PATCH — Completar cadastro para bater com PatientsCadastroPage.vue
+-- (rode DEPOIS do seu supabase_cadastro_pacientes.sql)
+-- =========================================================
+
+create extension if not exists pgcrypto;
+
+-- ---------------------------------------------------------
+-- 1) Completar colunas que o front usa e hoje faltam em patients
+-- ---------------------------------------------------------
+do $$
+begin
+ if not exists (
+ select 1 from information_schema.columns
+ where table_schema='public' and table_name='patients' and column_name='email_alt'
+ ) then
+ alter table public.patients add column email_alt text;
+ end if;
+
+ if not exists (
+ select 1 from information_schema.columns
+ where table_schema='public' and table_name='patients' and column_name='phones'
+ ) then
+ -- array de textos (Postgres). No JS você manda ["...","..."] normalmente.
+ alter table public.patients add column phones text[];
+ end if;
+
+ if not exists (
+ select 1 from information_schema.columns
+ where table_schema='public' and table_name='patients' and column_name='gender'
+ ) then
+ alter table public.patients add column gender text;
+ end if;
+
+ if not exists (
+ select 1 from information_schema.columns
+ where table_schema='public' and table_name='patients' and column_name='marital_status'
+ ) then
+ alter table public.patients add column marital_status text;
+ end if;
+end $$;
+
+-- (opcional) índices úteis pra busca/filtro por nome/email
+create index if not exists idx_patients_owner_name on public.patients(owner_id, name);
+create index if not exists idx_patients_owner_email on public.patients(owner_id, email);
+
+-- ---------------------------------------------------------
+-- 2) patient_groups
+-- ---------------------------------------------------------
+create table if not exists public.patient_groups (
+ id uuid primary key default gen_random_uuid(),
+ owner_id uuid not null references auth.users(id) on delete cascade,
+ name text not null,
+ color text,
+ is_system boolean not null default false,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
+);
+
+-- nome único por owner
+do $$
+begin
+ if not exists (
+ select 1 from pg_constraint
+ where conname = 'patient_groups_owner_name_uniq'
+ and conrelid = 'public.patient_groups'::regclass
+ ) then
+ alter table public.patient_groups
+ add constraint patient_groups_owner_name_uniq unique(owner_id, name);
+ end if;
+end $$;
+
+drop trigger if exists trg_patient_groups_set_updated_at on public.patient_groups;
+create trigger trg_patient_groups_set_updated_at
+before update on public.patient_groups
+for each row execute function public.set_updated_at();
+
+create index if not exists idx_patient_groups_owner on public.patient_groups(owner_id);
+
+alter table public.patient_groups enable row level security;
+
+drop policy if exists "patient_groups_select_own" on public.patient_groups;
+create policy "patient_groups_select_own"
+on public.patient_groups for select
+to authenticated
+using (owner_id = auth.uid());
+
+drop policy if exists "patient_groups_insert_own" on public.patient_groups;
+create policy "patient_groups_insert_own"
+on public.patient_groups for insert
+to authenticated
+with check (owner_id = auth.uid());
+
+drop policy if exists "patient_groups_update_own" on public.patient_groups;
+create policy "patient_groups_update_own"
+on public.patient_groups for update
+to authenticated
+using (owner_id = auth.uid())
+with check (owner_id = auth.uid());
+
+drop policy if exists "patient_groups_delete_own" on public.patient_groups;
+create policy "patient_groups_delete_own"
+on public.patient_groups for delete
+to authenticated
+using (owner_id = auth.uid());
+
+grant select, insert, update, delete on public.patient_groups to authenticated;
+
+-- ---------------------------------------------------------
+-- 3) patient_tags
+-- ---------------------------------------------------------
+create table if not exists public.patient_tags (
+ id uuid primary key default gen_random_uuid(),
+ owner_id uuid not null references auth.users(id) on delete cascade,
+ name text not null,
+ color text,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
+);
+
+do $$
+begin
+ if not exists (
+ select 1 from pg_constraint
+ where conname = 'patient_tags_owner_name_uniq'
+ and conrelid = 'public.patient_tags'::regclass
+ ) then
+ alter table public.patient_tags
+ add constraint patient_tags_owner_name_uniq unique(owner_id, name);
+ end if;
+end $$;
+
+drop trigger if exists trg_patient_tags_set_updated_at on public.patient_tags;
+create trigger trg_patient_tags_set_updated_at
+before update on public.patient_tags
+for each row execute function public.set_updated_at();
+
+create index if not exists idx_patient_tags_owner on public.patient_tags(owner_id);
+
+alter table public.patient_tags enable row level security;
+
+drop policy if exists "patient_tags_select_own" on public.patient_tags;
+create policy "patient_tags_select_own"
+on public.patient_tags for select
+to authenticated
+using (owner_id = auth.uid());
+
+drop policy if exists "patient_tags_insert_own" on public.patient_tags;
+create policy "patient_tags_insert_own"
+on public.patient_tags for insert
+to authenticated
+with check (owner_id = auth.uid());
+
+drop policy if exists "patient_tags_update_own" on public.patient_tags;
+create policy "patient_tags_update_own"
+on public.patient_tags for update
+to authenticated
+using (owner_id = auth.uid())
+with check (owner_id = auth.uid());
+
+drop policy if exists "patient_tags_delete_own" on public.patient_tags;
+create policy "patient_tags_delete_own"
+on public.patient_tags for delete
+to authenticated
+using (owner_id = auth.uid());
+
+grant select, insert, update, delete on public.patient_tags to authenticated;
+
+-- ---------------------------------------------------------
+-- 4) pivôs (patient_group_patient / patient_patient_tag)
+-- ---------------------------------------------------------
+create table if not exists public.patient_group_patient (
+ patient_id uuid not null references public.patients(id) on delete cascade,
+ patient_group_id uuid not null references public.patient_groups(id) on delete cascade,
+ created_at timestamptz not null default now(),
+ primary key (patient_id, patient_group_id)
+);
+
+create index if not exists idx_pgp_patient on public.patient_group_patient(patient_id);
+create index if not exists idx_pgp_group on public.patient_group_patient(patient_group_id);
+
+alter table public.patient_group_patient enable row level security;
+
+-- a pivot “herda” tenant via join; policy usando exists pra validar owner do patient
+drop policy if exists "pgp_select_own" on public.patient_group_patient;
+create policy "pgp_select_own"
+on public.patient_group_patient for select
+to authenticated
+using (
+ exists (
+ select 1 from public.patients p
+ where p.id = patient_group_patient.patient_id
+ and p.owner_id = auth.uid()
+ )
+);
+
+drop policy if exists "pgp_write_own" on public.patient_group_patient;
+create policy "pgp_write_own"
+on public.patient_group_patient for all
+to authenticated
+using (
+ exists (
+ select 1 from public.patients p
+ where p.id = patient_group_patient.patient_id
+ and p.owner_id = auth.uid()
+ )
+)
+with check (
+ exists (
+ select 1 from public.patients p
+ where p.id = patient_group_patient.patient_id
+ and p.owner_id = auth.uid()
+ )
+);
+
+grant select, insert, update, delete on public.patient_group_patient to authenticated;
+
+-- tags pivot (ATENÇÃO: coluna é tag_id, como teu Vue usa!)
+create table if not exists public.patient_patient_tag (
+ patient_id uuid not null references public.patients(id) on delete cascade,
+ tag_id uuid not null references public.patient_tags(id) on delete cascade,
+ created_at timestamptz not null default now(),
+ primary key (patient_id, tag_id)
+);
+
+create index if not exists idx_ppt_patient on public.patient_patient_tag(patient_id);
+create index if not exists idx_ppt_tag on public.patient_patient_tag(tag_id);
+
+alter table public.patient_patient_tag enable row level security;
+
+drop policy if exists "ppt_select_own" on public.patient_patient_tag;
+create policy "ppt_select_own"
+on public.patient_patient_tag for select
+to authenticated
+using (
+ exists (
+ select 1 from public.patients p
+ where p.id = patient_patient_tag.patient_id
+ and p.owner_id = auth.uid()
+ )
+);
+
+drop policy if exists "ppt_write_own" on public.patient_patient_tag;
+create policy "ppt_write_own"
+on public.patient_patient_tag for all
+to authenticated
+using (
+ exists (
+ select 1 from public.patients p
+ where p.id = patient_patient_tag.patient_id
+ and p.owner_id = auth.uid()
+ )
+)
+with check (
+ exists (
+ select 1 from public.patients p
+ where p.id = patient_patient_tag.patient_id
+ and p.owner_id = auth.uid()
+ )
+);
+
+grant select, insert, update, delete on public.patient_patient_tag to authenticated;
+
+-- =========================================================
+-- FIM PATCH
+-- =========================================================
diff --git a/src/sql-arquivos/supabase_cadastros_recebidos(intakes).sql b/src/sql-arquivos/supabase_cadastros_recebidos(intakes).sql
new file mode 100644
index 0000000..3e43d42
--- /dev/null
+++ b/src/sql-arquivos/supabase_cadastros_recebidos(intakes).sql
@@ -0,0 +1,105 @@
+-- =========================================================
+-- INTakes / Cadastros Recebidos - Supabase Local
+-- =========================================================
+
+-- 0) Extensões úteis (geralmente já existem no Supabase, mas é seguro)
+create extension if not exists pgcrypto;
+
+-- 1) Função padrão para updated_at
+create or replace function public.set_updated_at()
+returns trigger
+language plpgsql
+as $$
+begin
+ new.updated_at = now();
+ return new;
+end;
+$$;
+
+-- 2) Tabela patient_intake_requests (espelhando nuvem)
+create table if not exists public.patient_intake_requests (
+ id uuid primary key default gen_random_uuid(),
+ owner_id uuid not null,
+ token text,
+ name text,
+ email text,
+ phone text,
+ notes text,
+ consent boolean not null default false,
+ status text not null default 'new',
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now(),
+ payload jsonb
+);
+
+-- 3) Índices (performance em listagem e filtros)
+create index if not exists idx_intakes_owner_created
+ on public.patient_intake_requests (owner_id, created_at desc);
+
+create index if not exists idx_intakes_owner_status_created
+ on public.patient_intake_requests (owner_id, status, created_at desc);
+
+create index if not exists idx_intakes_status_created
+ on public.patient_intake_requests (status, created_at desc);
+
+-- 4) Trigger updated_at
+drop trigger if exists trg_patient_intake_requests_updated_at on public.patient_intake_requests;
+
+create trigger trg_patient_intake_requests_updated_at
+before update on public.patient_intake_requests
+for each row execute function public.set_updated_at();
+
+-- 5) RLS
+alter table public.patient_intake_requests enable row level security;
+
+-- 6) Policies (iguais às que você mostrou na nuvem)
+drop policy if exists intake_select_own on public.patient_intake_requests;
+create policy intake_select_own
+on public.patient_intake_requests
+for select
+to authenticated
+using (owner_id = auth.uid());
+
+drop policy if exists intake_update_own on public.patient_intake_requests;
+create policy intake_update_own
+on public.patient_intake_requests
+for update
+to authenticated
+using (owner_id = auth.uid())
+with check (owner_id = auth.uid());
+
+drop policy if exists "delete own intake requests" on public.patient_intake_requests;
+create policy "delete own intake requests"
+on public.patient_intake_requests
+for delete
+to authenticated
+using (owner_id = auth.uid());
+
+-- =========================================================
+-- OPCIONAL (RECOMENDADO): registrar conversão
+-- =========================================================
+-- Se você pretende marcar intake como convertido e guardar o patient_id:
+alter table public.patient_intake_requests
+ add column if not exists converted_patient_id uuid;
+
+create index if not exists idx_intakes_converted_patient_id
+ on public.patient_intake_requests (converted_patient_id);
+
+-- Opcional: impedir delete de intakes convertidos (melhor para auditoria)
+-- (Se quiser manter delete liberado como na nuvem, comente este bloco.)
+drop policy if exists "delete own intake requests" on public.patient_intake_requests;
+create policy "delete_own_intakes_not_converted"
+on public.patient_intake_requests
+for delete
+to authenticated
+using (owner_id = auth.uid() and status <> 'converted');
+
+-- =========================================================
+-- OPCIONAL: check de status (evita status inválido)
+-- =========================================================
+alter table public.patient_intake_requests
+ drop constraint if exists chk_intakes_status;
+
+alter table public.patient_intake_requests
+ add constraint chk_intakes_status
+ check (status in ('new', 'converted', 'rejected'));
diff --git a/src/sql-arquivos/supabase_patient_groups.sql b/src/sql-arquivos/supabase_patient_groups.sql
new file mode 100644
index 0000000..08070b4
--- /dev/null
+++ b/src/sql-arquivos/supabase_patient_groups.sql
@@ -0,0 +1,174 @@
+/*
+ patient_groups_setup.sql
+ Setup completo para:
+ - public.patient_groups
+ - public.patient_group_patient (tabela ponte)
+ - view public.v_patient_groups_with_counts
+ - índice único por owner + nome (case-insensitive)
+ - 3 grupos padrão do sistema (Crianças, Adolescentes, Idosos) NÃO editáveis / NÃO removíveis
+ - triggers de proteção
+
+ Observação (importante):
+ - Os grupos padrão são criados com owner_id = '00000000-0000-0000-0000-000000000000' (SYSTEM_OWNER),
+ para ficarem "globais" e não dependerem de auth.uid() em migrations.
+ - Se você quiser que os grupos padrão pertençam a um owner específico (tenant),
+ basta trocar o SYSTEM_OWNER abaixo por esse UUID.
+*/
+
+begin;
+
+-- ===========================
+-- 0) Constante de "dono do sistema"
+-- ===========================
+-- Troque aqui se você quiser que os grupos padrão pertençam a um owner específico.
+-- Ex.: '816b24fe-a0c3-4409-b79b-c6c0a6935d03'
+do $$
+begin
+ -- só para documentar; não cria nada
+end $$;
+
+-- ===========================
+-- 1) Tabela principal: patient_groups
+-- ===========================
+create table if not exists public.patient_groups (
+ id uuid primary key default gen_random_uuid(),
+ name text not null,
+ description text,
+ color text,
+ is_active boolean not null default true,
+ is_system boolean not null default false,
+ owner_id uuid not null,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz not null default now()
+);
+
+-- (Opcional, mas recomendado) Garante que name não seja só espaços
+-- e evita nomes vazios.
+alter table public.patient_groups
+ drop constraint if exists patient_groups_name_not_blank_check;
+
+alter table public.patient_groups
+ add constraint patient_groups_name_not_blank_check
+ check (length(btrim(name)) > 0);
+
+-- ===========================
+-- 2) Tabela ponte: patient_group_patient
+-- ===========================
+-- Se você já tiver essa tabela com FKs, ajuste aqui conforme seu schema.
+create table if not exists public.patient_group_patient (
+ patient_group_id uuid not null references public.patient_groups(id) on delete cascade,
+ patient_id uuid not null references public.patients(id) on delete cascade,
+ created_at timestamptz not null default now()
+);
+
+-- Evita duplicar vínculo paciente<->grupo
+create unique index if not exists patient_group_patient_unique
+on public.patient_group_patient (patient_group_id, patient_id);
+
+-- ===========================
+-- 3) View com contagem
+-- ===========================
+create or replace view public.v_patient_groups_with_counts as
+select
+ g.*,
+ coalesce(count(distinct pgp.patient_id), 0)::int as patients_count
+from public.patient_groups g
+left join public.patient_group_patient pgp
+ on pgp.patient_group_id = g.id
+group by g.id;
+
+-- ===========================
+-- 4) Índice único: não permitir mesmo nome por owner (case-insensitive)
+-- ===========================
+-- Atenção: se já existirem duplicados, este índice pode falhar ao criar.
+create unique index if not exists patient_groups_owner_name_unique
+on public.patient_groups (owner_id, (lower(name)));
+
+-- ===========================
+-- 5) Triggers de proteção: system não edita / não remove
+-- ===========================
+create or replace function public.prevent_system_group_changes()
+returns trigger
+language plpgsql
+as $$
+begin
+ if old.is_system = true then
+ raise exception 'Grupos padrão do sistema não podem ser alterados ou excluídos.';
+ end if;
+
+ if tg_op = 'DELETE' then
+ return old;
+ end if;
+
+ return new;
+end;
+$$;
+
+drop trigger if exists trg_prevent_system_group_changes on public.patient_groups;
+
+create trigger trg_prevent_system_group_changes
+before update or delete on public.patient_groups
+for each row
+execute function public.prevent_system_group_changes();
+
+-- Impede "promover" um grupo comum para system
+create or replace function public.prevent_promoting_to_system()
+returns trigger
+language plpgsql
+as $$
+begin
+ if new.is_system = true and old.is_system is distinct from true then
+ raise exception 'Não é permitido transformar um grupo comum em grupo do sistema.';
+ end if;
+ return new;
+end;
+$$;
+
+drop trigger if exists trg_prevent_promoting_to_system on public.patient_groups;
+
+create trigger trg_prevent_promoting_to_system
+before update on public.patient_groups
+for each row
+execute function public.prevent_promoting_to_system();
+
+-- ===========================
+-- 6) Inserir 3 grupos padrão (imutáveis)
+-- ===========================
+-- Dono "global" do sistema (mude se quiser):
+-- 00000000-0000-0000-0000-000000000000
+with sys_owner as (
+ select '00000000-0000-0000-0000-000000000000'::uuid as owner_id
+)
+insert into public.patient_groups (name, description, color, is_active, is_system, owner_id)
+select v.name, v.description, v.color, v.is_active, v.is_system, s.owner_id
+from sys_owner s
+join (values
+ ('Crianças', 'Grupo padrão do sistema', null, true, true),
+ ('Adolescentes', 'Grupo padrão do sistema', null, true, true),
+ ('Idosos', 'Grupo padrão do sistema', null, true, true)
+) as v(name, description, color, is_active, is_system)
+on true
+where not exists (
+ select 1
+ from public.patient_groups g
+ where g.owner_id = s.owner_id
+ and lower(g.name) = lower(v.name)
+);
+
+commit;
+
+/*
+ Testes rápidos:
+ 1) Ver tudo:
+ select * from public.v_patient_groups_with_counts order by is_system desc, name;
+
+ 2) Tentar editar um system (deve falhar):
+ update public.patient_groups set name='X' where name='Crianças';
+
+ 3) Tentar deletar um system (deve falhar):
+ delete from public.patient_groups where name='Crianças';
+
+ 4) Tentar duplicar nome no mesmo owner (deve falhar por índice único):
+ insert into public.patient_groups (name, is_active, is_system, owner_id)
+ values ('teste22', true, false, '816b24fe-a0c3-4409-b79b-c6c0a6935d03');
+*/
diff --git a/src/sql-arquivos/supabase_patient_index_page.sql b/src/sql-arquivos/supabase_patient_index_page.sql
new file mode 100644
index 0000000..c8def53
--- /dev/null
+++ b/src/sql-arquivos/supabase_patient_index_page.sql
@@ -0,0 +1,147 @@
+-- =========================================================
+-- pacientesIndexPage.sql
+-- Views + índices para a tela PatientsIndexPage
+-- =========================================================
+
+-- 0) Extensões úteis
+create extension if not exists pg_trgm;
+
+-- 1) updated_at automático (se você quiser manter updated_at sempre correto)
+create or replace function public.set_updated_at()
+returns trigger
+language plpgsql
+as $$
+begin
+ new.updated_at = now();
+ return new;
+end;
+$$;
+
+drop trigger if exists trg_patients_set_updated_at on public.patients;
+create trigger trg_patients_set_updated_at
+before update on public.patients
+for each row execute function public.set_updated_at();
+
+
+-- =========================================================
+-- 2) Views de contagem (usadas em KPIs e telas auxiliares)
+-- =========================================================
+
+-- 2.1) Grupos com contagem de pacientes
+create or replace view public.v_patient_groups_with_counts as
+select
+ g.id,
+ g.name,
+ g.color,
+ coalesce(count(pgp.patient_id), 0)::int as patients_count
+from public.patient_groups g
+left join public.patient_group_patient pgp
+ on pgp.patient_group_id = g.id
+group by g.id, g.name, g.color;
+
+-- 2.2) Tags com contagem de pacientes
+create or replace view public.v_tag_patient_counts as
+select
+ t.id,
+ t.name,
+ t.color,
+ coalesce(count(ppt.patient_id), 0)::int as patients_count
+from public.patient_tags t
+left join public.patient_patient_tag ppt
+ on ppt.tag_id = t.id
+group by t.id, t.name, t.color;
+
+
+-- =========================================================
+-- 3) View principal da Index (pacientes + grupos/tags agregados)
+-- =========================================================
+
+create or replace view public.v_patients_index as
+select
+ p.*,
+
+ -- array JSON com os grupos do paciente
+ coalesce(gx.groups, '[]'::jsonb) as groups,
+
+ -- array JSON com as tags do paciente
+ coalesce(tx.tags, '[]'::jsonb) as tags,
+
+ -- contagens para UI/KPIs
+ coalesce(gx.groups_count, 0)::int as groups_count,
+ coalesce(tx.tags_count, 0)::int as tags_count
+
+from public.patients p
+
+left join lateral (
+ select
+ jsonb_agg(
+ distinct jsonb_build_object(
+ 'id', g.id,
+ 'name', g.name,
+ 'color', g.color
+ )
+ ) filter (where g.id is not null) as groups,
+ count(distinct g.id) as groups_count
+ from public.patient_group_patient pgp
+ join public.patient_groups g
+ on g.id = pgp.patient_group_id
+ where pgp.patient_id = p.id
+) gx on true
+
+left join lateral (
+ select
+ jsonb_agg(
+ distinct jsonb_build_object(
+ 'id', t.id,
+ 'name', t.name,
+ 'color', t.color
+ )
+ ) filter (where t.id is not null) as tags,
+ count(distinct t.id) as tags_count
+ from public.patient_patient_tag ppt
+ join public.patient_tags t
+ on t.id = ppt.tag_id
+ where ppt.patient_id = p.id
+) tx on true;
+
+
+-- =========================================================
+-- 4) Índices recomendados (performance real na listagem/filtros)
+-- =========================================================
+
+-- Patients
+create index if not exists idx_patients_owner_id
+ on public.patients (owner_id);
+
+create index if not exists idx_patients_created_at
+ on public.patients (created_at desc);
+
+create index if not exists idx_patients_status
+ on public.patients (status);
+
+create index if not exists idx_patients_last_attended_at
+ on public.patients (last_attended_at desc);
+
+-- Busca rápida (name/email/phone)
+create index if not exists idx_patients_name_trgm
+ on public.patients using gin (name gin_trgm_ops);
+
+create index if not exists idx_patients_email_trgm
+ on public.patients using gin (email gin_trgm_ops);
+
+create index if not exists idx_patients_phone_trgm
+ on public.patients using gin (phone gin_trgm_ops);
+
+-- Pivot: grupos
+create index if not exists idx_pgp_patient_id
+ on public.patient_group_patient (patient_id);
+
+create index if not exists idx_pgp_group_id
+ on public.patient_group_patient (patient_group_id);
+
+-- Pivot: tags
+create index if not exists idx_ppt_patient_id
+ on public.patient_patient_tag (patient_id);
+
+create index if not exists idx_ppt_tag_id
+ on public.patient_patient_tag (tag_id);
diff --git a/src/sql-arquivos/supabase_patients_populate.sql b/src/sql-arquivos/supabase_patients_populate.sql
new file mode 100644
index 0000000..e69de29
diff --git a/src/sql-arquivos/supabase_tags.sql b/src/sql-arquivos/supabase_tags.sql
new file mode 100644
index 0000000..42668af
--- /dev/null
+++ b/src/sql-arquivos/supabase_tags.sql
@@ -0,0 +1,134 @@
+create extension if not exists pgcrypto;
+
+-- ===============================
+-- TABELA: patient_tags
+-- ===============================
+create table if not exists public.patient_tags (
+ id uuid primary key default gen_random_uuid(),
+ owner_id uuid not null,
+ name text not null,
+ color text,
+ is_native boolean not null default false,
+ created_at timestamptz not null default now(),
+ updated_at timestamptz
+);
+
+create unique index if not exists patient_tags_owner_name_uq
+ on public.patient_tags (owner_id, lower(name));
+
+-- ===============================
+-- TABELA: patient_patient_tag (pivot)
+-- ===============================
+create table if not exists public.patient_patient_tag (
+ owner_id uuid not null,
+ patient_id uuid not null,
+ tag_id uuid not null,
+ created_at timestamptz not null default now(),
+ primary key (patient_id, tag_id)
+);
+
+create index if not exists ppt_owner_idx on public.patient_patient_tag(owner_id);
+create index if not exists ppt_tag_idx on public.patient_patient_tag(tag_id);
+create index if not exists ppt_patient_idx on public.patient_patient_tag(patient_id);
+
+-- ===============================
+-- FOREIGN KEYS (com checagem)
+-- ===============================
+do $$
+begin
+ if not exists (
+ select 1 from pg_constraint
+ where conname = 'ppt_tag_fk'
+ and conrelid = 'public.patient_patient_tag'::regclass
+ ) then
+ alter table public.patient_patient_tag
+ add constraint ppt_tag_fk
+ foreign key (tag_id)
+ references public.patient_tags(id)
+ on delete cascade;
+ end if;
+end $$;
+
+do $$
+begin
+ if not exists (
+ select 1 from pg_constraint
+ where conname = 'ppt_patient_fk'
+ and conrelid = 'public.patient_patient_tag'::regclass
+ ) then
+ alter table public.patient_patient_tag
+ add constraint ppt_patient_fk
+ foreign key (patient_id)
+ references public.patients(id)
+ on delete cascade;
+ end if;
+end $$;
+
+-- ===============================
+-- VIEW: contagem por tag
+-- ===============================
+create or replace view public.v_tag_patient_counts as
+select
+ t.id,
+ t.owner_id,
+ t.name,
+ t.color,
+ t.is_native,
+ t.created_at,
+ t.updated_at,
+ coalesce(count(ppt.patient_id), 0)::int as patient_count
+from public.patient_tags t
+left join public.patient_patient_tag ppt
+ on ppt.tag_id = t.id
+ and ppt.owner_id = t.owner_id
+group by
+ t.id, t.owner_id, t.name, t.color, t.is_native, t.created_at, t.updated_at;
+
+-- ===============================
+-- RLS
+-- ===============================
+alter table public.patient_tags enable row level security;
+alter table public.patient_patient_tag enable row level security;
+
+drop policy if exists tags_select_own on public.patient_tags;
+create policy tags_select_own
+on public.patient_tags
+for select
+using (owner_id = auth.uid());
+
+drop policy if exists tags_insert_own on public.patient_tags;
+create policy tags_insert_own
+on public.patient_tags
+for insert
+with check (owner_id = auth.uid());
+
+drop policy if exists tags_update_own on public.patient_tags;
+create policy tags_update_own
+on public.patient_tags
+for update
+using (owner_id = auth.uid())
+with check (owner_id = auth.uid());
+
+drop policy if exists tags_delete_own on public.patient_tags;
+create policy tags_delete_own
+on public.patient_tags
+for delete
+using (owner_id = auth.uid());
+
+drop policy if exists ppt_select_own on public.patient_patient_tag;
+create policy ppt_select_own
+on public.patient_patient_tag
+for select
+using (owner_id = auth.uid());
+
+drop policy if exists ppt_insert_own on public.patient_patient_tag;
+create policy ppt_insert_own
+on public.patient_patient_tag
+for insert
+with check (owner_id = auth.uid());
+
+drop policy if exists ppt_delete_own on public.patient_patient_tag;
+create policy ppt_delete_own
+on public.patient_patient_tag
+for delete
+using (owner_id = auth.uid());
diff --git a/src/stores/entitlementsStore.js b/src/stores/entitlementsStore.js
new file mode 100644
index 0000000..2aa8fd5
--- /dev/null
+++ b/src/stores/entitlementsStore.js
@@ -0,0 +1,107 @@
+// src/stores/entitlementsStore.js
+import { defineStore } from 'pinia'
+import { supabase } from '@/lib/supabase/client'
+
+function normalizeKey(k) {
+ return String(k || '').trim()
+}
+
+export const useEntitlementsStore = defineStore('entitlements', {
+ state: () => ({
+ loading: false,
+ loadedForTenant: null,
+ features: [], // array reativo de feature_key liberadas
+ raw: [],
+ error: null,
+ loadedAt: null
+ }),
+
+ getters: {
+ can: (state) => (featureKey) => state.features.includes(featureKey),
+ has: (state) => (featureKey) => state.features.includes(featureKey)
+ },
+
+ actions: {
+ async fetch(tenantId, opts = {}) {
+ return this.loadForTenant(tenantId, opts)
+ },
+
+ clear() {
+ return this.invalidate()
+ },
+
+ /**
+ * Carrega entitlements do tenant.
+ * Importante: quando o plano muda, tenantId é o mesmo,
+ * então você DEVE chamar com { force: true }.
+ *
+ * opts:
+ * - force: ignora cache do tenant
+ * - maxAgeMs: se definido, recarrega quando loadedAt estiver velho
+ */
+ async loadForTenant(tenantId, { force = false, maxAgeMs = 0 } = {}) {
+ if (!tenantId) {
+ this.invalidate()
+ return
+ }
+
+ const sameTenant = this.loadedForTenant === tenantId
+ const hasLoadedAt = typeof this.loadedAt === 'number'
+ const isFresh =
+ sameTenant &&
+ hasLoadedAt &&
+ maxAgeMs > 0 &&
+ Date.now() - this.loadedAt < maxAgeMs
+
+ if (!force && sameTenant && (maxAgeMs === 0 || isFresh)) return
+
+ this.loading = true
+ this.error = null
+
+ try {
+ // ✅ Modelo B: entitlements por tenant (view)
+ const { data, error } = await supabase
+ .from('v_tenant_entitlements')
+ .select('feature_key')
+ .eq('tenant_id', tenantId)
+
+ if (error) throw error
+
+ const rows = data ?? []
+
+ // normaliza, remove vazios e duplicados
+ const list = []
+ const seen = new Set()
+ for (const r of rows) {
+ const key = normalizeKey(r?.feature_key)
+ if (!key) continue
+ if (seen.has(key)) continue
+ seen.add(key)
+ list.push(key)
+ }
+
+ this.raw = rows
+ this.features = list
+ this.loadedForTenant = tenantId
+ this.loadedAt = Date.now()
+ } catch (e) {
+ this.error = e
+ this.raw = []
+ this.features = []
+ this.loadedForTenant = tenantId
+ this.loadedAt = Date.now()
+ } finally {
+ this.loading = false
+ }
+ },
+
+ invalidate() {
+ this.loadedForTenant = null
+ this.features = []
+ this.raw = []
+ this.error = null
+ this.loadedAt = null
+ this.loading = false
+ }
+ }
+})
diff --git a/src/stores/saasHealthStore.js b/src/stores/saasHealthStore.js
new file mode 100644
index 0000000..3ac54e3
--- /dev/null
+++ b/src/stores/saasHealthStore.js
@@ -0,0 +1,31 @@
+// src/stores/saasHealthStore.js
+import { defineStore } from 'pinia'
+import { supabase } from '@/lib/supabase/client'
+
+export const useSaasHealthStore = defineStore('saasHealth', {
+ state: () => ({
+ mismatchCount: 0,
+ loading: false,
+ lastLoadedAt: null
+ }),
+
+ actions: {
+ async loadMismatchCount ({ force = false } = {}) {
+ if (this.loading) return
+ if (!force && this.lastLoadedAt && (Date.now() - this.lastLoadedAt) < 30_000) return // cache 30s
+
+ this.loading = true
+ try {
+ const { count, error } = await supabase
+ .from('v_subscription_feature_mismatch')
+ .select('*', { count: 'exact', head: true })
+
+ if (error) throw error
+ this.mismatchCount = Number(count || 0)
+ this.lastLoadedAt = Date.now()
+ } finally {
+ this.loading = false
+ }
+ }
+ }
+})
diff --git a/src/stores/tenantStore.js b/src/stores/tenantStore.js
new file mode 100644
index 0000000..2fe13bf
--- /dev/null
+++ b/src/stores/tenantStore.js
@@ -0,0 +1,86 @@
+// src/stores/tenantStore.js
+import { defineStore } from 'pinia'
+import { supabase } from '@/lib/supabase/client'
+
+export const useTenantStore = defineStore('tenant', {
+ state: () => ({
+ loading: false,
+ loaded: false,
+
+ user: null, // auth user
+ memberships: [], // [{ tenant_id, role, status }]
+ activeTenantId: null,
+ activeRole: null,
+
+ needsTenantLink: false,
+ error: null
+ }),
+
+ actions: {
+ async loadSessionAndTenant () {
+ 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
+
+ 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
+ }
+
+ // 2) memberships via RPC
+ const { data: mem, error: mErr } = await supabase.rpc('my_tenants')
+ if (mErr) throw mErr
+
+ this.memberships = Array.isArray(mem) ? mem : []
+
+ // 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
+
+ // 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.
+ // 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
+ }
+
+ 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
+ }
+ }
+})
diff --git a/src/theme/theme.options.js b/src/theme/theme.options.js
new file mode 100644
index 0000000..a07ddd1
--- /dev/null
+++ b/src/theme/theme.options.js
@@ -0,0 +1,140 @@
+// src/theme/theme.options.js
+import { $t, updatePreset, updateSurfacePalette } from '@primeuix/themes'
+import Aura from '@primeuix/themes/aura'
+import Lara from '@primeuix/themes/lara'
+import Nora from '@primeuix/themes/nora'
+
+/**
+ * Presets
+ */
+export const presetsMap = { Aura, Lara, Nora }
+export const presetOptions = Object.keys(presetsMap)
+
+/**
+ * Colors (Primary)
+ */
+export const primaryColors = [
+ { name: 'noir', palette: {} },
+ { name: 'emerald', palette: { 50: '#ecfdf5', 100: '#d1fae5', 200: '#a7f3d0', 300: '#6ee7b7', 400: '#34d399', 500: '#10b981', 600: '#059669', 700: '#047857', 800: '#065f46', 900: '#064e3b', 950: '#022c22' } },
+ { name: 'green', palette: { 50: '#f0fdf4', 100: '#dcfce7', 200: '#bbf7d0', 300: '#86efac', 400: '#4ade80', 500: '#22c55e', 600: '#16a34a', 700: '#15803d', 800: '#166534', 900: '#14532d', 950: '#052e16' } },
+ { name: 'lime', palette: { 50: '#f7fee7', 100: '#ecfccb', 200: '#d9f99d', 300: '#bef264', 400: '#a3e635', 500: '#84cc16', 600: '#65a30d', 700: '#4d7c0f', 800: '#3f6212', 900: '#365314', 950: '#1a2e05' } },
+ { name: 'orange', palette: { 50: '#fff7ed', 100: '#ffedd5', 200: '#fed7aa', 300: '#fdba74', 400: '#fb923c', 500: '#f97316', 600: '#ea580c', 700: '#c2410c', 800: '#9a3412', 900: '#7c2d12', 950: '#431407' } },
+ { name: 'amber', palette: { 50: '#fffbeb', 100: '#fef3c7', 200: '#fde68a', 300: '#fcd34d', 400: '#fbbf24', 500: '#f59e0b', 600: '#d97706', 700: '#b45309', 800: '#92400e', 900: '#78350f', 950: '#451a03' } },
+ { name: 'yellow', palette: { 50: '#fefce8', 100: '#fef9c3', 200: '#fef08a', 300: '#fde047', 400: '#facc15', 500: '#eab308', 600: '#ca8a04', 700: '#a16207', 800: '#854d0e', 900: '#713f12', 950: '#422006' } },
+ { name: 'teal', palette: { 50: '#f0fdfa', 100: '#ccfbf1', 200: '#99f6e4', 300: '#5eead4', 400: '#2dd4bf', 500: '#14b8a6', 600: '#0d9488', 700: '#0f766e', 800: '#115e59', 900: '#134e4a', 950: '#042f2e' } },
+ { name: 'cyan', palette: { 50: '#ecfeff', 100: '#cffafe', 200: '#a5f3fc', 300: '#67e8f9', 400: '#22d3ee', 500: '#06b6d4', 600: '#0891b2', 700: '#0e7490', 800: '#155e75', 900: '#164e63', 950: '#083344' } },
+ { name: 'sky', palette: { 50: '#f0f9ff', 100: '#e0e2fe', 200: '#bae6fd', 300: '#7dd3fc', 400: '#38bdf8', 500: '#0ea5e9', 600: '#0284c7', 700: '#0369a1', 800: '#075985', 900: '#0c4a6e', 950: '#082f49' } },
+ { name: 'blue', palette: { 50: '#eff6ff', 100: '#dbeafe', 200: '#bfdbfe', 300: '#93c5fd', 400: '#60a5fa', 500: '#3b82f6', 600: '#2563eb', 700: '#1d4ed8', 800: '#1e40af', 900: '#1e3a8a', 950: '#172554' } },
+ { name: 'indigo', palette: { 50: '#eef2ff', 100: '#e0e7ff', 200: '#c7d2fe', 300: '#a5b4fc', 400: '#818cf8', 500: '#6366f1', 600: '#4f46e5', 700: '#4338ca', 800: '#3730a3', 900: '#312e81', 950: '#1e1b4b' } },
+ { name: 'violet', palette: { 50: '#f5f3ff', 100: '#ede9fe', 200: '#ddd6fe', 300: '#c4b5fd', 400: '#a78bfa', 500: '#8b5cf6', 600: '#7c3aed', 700: '#6d28d9', 800: '#5b21b6', 900: '#4c1d95', 950: '#2e1065' } },
+ { name: 'purple', palette: { 50: '#faf5ff', 100: '#f3e8ff', 200: '#e9d5ff', 300: '#d8b4fe', 400: '#c084fc', 500: '#a855f7', 600: '#9333ea', 700: '#7e22ce', 800: '#6b21a8', 900: '#581c87', 950: '#3b0764' } },
+ { name: 'fuchsia', palette: { 50: '#fdf4ff', 100: '#fae8ff', 200: '#f5d0fe', 300: '#f0abfc', 400: '#e879f9', 500: '#d946ef', 600: '#c026d3', 700: '#a21caf', 800: '#86198f', 900: '#701a75', 950: '#4a044e' } },
+ { name: 'pink', palette: { 50: '#fdf2f8', 100: '#fce7f3', 200: '#fbcfe8', 300: '#f9a8d4', 400: '#f472b6', 500: '#ec4899', 600: '#db2777', 700: '#be185d', 800: '#9d174d', 900: '#831843', 950: '#500724' } },
+ { name: 'rose', palette: { 50: '#fff1f2', 100: '#ffe4e6', 200: '#fecdd3', 300: '#fda4af', 400: '#fb7185', 500: '#f43f5e', 600: '#e11d48', 700: '#be123c', 800: '#9f1239', 900: '#881337', 950: '#4c0519' } }
+]
+
+/**
+ * Surfaces
+ */
+export const surfaces = [
+ { name: 'slate', palette: { 0: '#ffffff', 50: '#f8fafc', 100: '#f1f5f9', 200: '#e2e8f0', 300: '#cbd5e1', 400: '#94a3b8', 500: '#64748b', 600: '#475569', 700: '#334155', 800: '#1e293b', 900: '#0f172a', 950: '#020617' } },
+ { name: 'gray', palette: { 0: '#ffffff', 50: '#f9fafb', 100: '#f3f4f6', 200: '#e5e7eb', 300: '#d1d5db', 400: '#9ca3af', 500: '#6b7280', 600: '#4b5563', 700: '#374151', 800: '#1f2937', 900: '#111827', 950: '#030712' } },
+ { name: 'zinc', palette: { 0: '#ffffff', 50: '#fafafa', 100: '#f4f4f5', 200: '#e4e4e7', 300: '#d4d4d8', 400: '#a1a1aa', 500: '#71717a', 600: '#52525b', 700: '#3f3f46', 800: '#27272a', 900: '#18181b', 950: '#09090b' } },
+ { name: 'neutral', palette: { 0: '#ffffff', 50: '#fafafa', 100: '#f5f5f5', 200: '#e5e5e5', 300: '#d4d4d4', 400: '#a3a3a3', 500: '#737373', 600: '#525252', 700: '#404040', 800: '#262626', 900: '#171717', 950: '#0a0a0a' } },
+ { name: 'stone', palette: { 0: '#ffffff', 50: '#fafaf9', 100: '#f5f5f4', 200: '#e7e5e4', 300: '#d6d3d1', 400: '#a8a29e', 500: '#78716c', 600: '#57534e', 700: '#44403c', 800: '#292524', 900: '#1c1917', 950: '#0c0a09' } },
+ { name: 'soho', palette: { 0: '#ffffff', 50: '#f4f4f4', 100: '#e8e9e9', 200: '#d2d2d4', 300: '#bbbcbe', 400: '#a5a5a9', 500: '#8e8f93', 600: '#77787d', 700: '#616268', 800: '#4a4b52', 900: '#34343d', 950: '#1d1e27' } },
+ { name: 'viva', palette: { 0: '#ffffff', 50: '#f3f3f3', 100: '#e7e7e8', 200: '#cfd0d0', 300: '#b7b8b9', 400: '#9fa1a1', 500: '#87898a', 600: '#6e7173', 700: '#565a5b', 800: '#3e4244', 900: '#262b2c', 950: '#0e1315' } },
+ { name: 'ocean', palette: { 0: '#ffffff', 50: '#fbfcfc', 100: '#F7F9F8', 200: '#EFF3F2', 300: '#DADEDD', 400: '#B1B7B6', 500: '#828787', 600: '#5F7274', 700: '#415B61', 800: '#29444E', 900: '#183240', 950: '#0c1920' } }
+]
+
+/**
+ * ✅ noir: primary “vira” surface (o bloco que você pediu pra ficar aqui)
+ */
+export const noirPrimaryFromSurface = {
+ 50: '{surface.50}',
+ 100: '{surface.100}',
+ 200: '{surface.200}',
+ 300: '{surface.300}',
+ 400: '{surface.400}',
+ 500: '{surface.500}',
+ 600: '{surface.600}',
+ 700: '{surface.700}',
+ 800: '{surface.800}',
+ 900: '{surface.900}',
+ 950: '{surface.950}'
+}
+
+/**
+ * Helpers
+ */
+export function getSurfacePalette(surfaceName) {
+ return surfaces.find(s => s.name === surfaceName)?.palette
+}
+
+/**
+ * ✅ Ponto único: “Preset Extension” baseado no layoutConfig atual
+ * Use assim: updatePreset(getPresetExt(layoutConfig))
+ */
+export function getPresetExt(layoutConfig) {
+ const primaryName = layoutConfig?.primary || 'noir'
+ const color = primaryColors.find(c => c.name === primaryName) || { name: 'noir', palette: {} }
+
+ if (color.name === 'noir') {
+ return {
+ semantic: {
+ primary: noirPrimaryFromSurface,
+ colorScheme: {
+ light: {
+ primary: { color: '{primary.950}', contrastColor: '#ffffff', hoverColor: '{primary.800}', activeColor: '{primary.700}' },
+ highlight: { background: '{primary.950}', focusBackground: '{primary.700}', color: '#ffffff', focusColor: '#ffffff' }
+ },
+ dark: {
+ primary: { color: '{primary.50}', contrastColor: '{primary.950}', hoverColor: '{primary.200}', activeColor: '{primary.300}' },
+ highlight: { background: '{primary.50}', focusBackground: '{primary.300}', color: '{primary.950}', focusColor: '{primary.950}' }
+ }
+ }
+ }
+ }
+ }
+
+ return {
+ semantic: {
+ primary: color.palette,
+ colorScheme: {
+ light: {
+ primary: { color: '{primary.500}', contrastColor: '#ffffff', hoverColor: '{primary.600}', activeColor: '{primary.700}' },
+ highlight: { background: '{primary.50}', focusBackground: '{primary.100}', color: '{primary.700}', focusColor: '{primary.800}' }
+ },
+ dark: {
+ primary: { color: '{primary.400}', contrastColor: '{surface.900}', hoverColor: '{primary.300}', activeColor: '{primary.200}' },
+ highlight: {
+ background: 'color-mix(in srgb, {primary.400}, transparent 84%)',
+ focusBackground: 'color-mix(in srgb, {primary.400}, transparent 76%)',
+ color: 'rgba(255,255,255,.87)',
+ focusColor: 'rgba(255,255,255,.87)'
+ }
+ }
+ }
+ }
+ }
+}
+
+export function applyThemeEngine(layoutConfig) {
+ const presetValue = presetsMap?.[layoutConfig?.preset] || presetsMap.Aura
+ const surfacePalette = getSurfacePalette(layoutConfig?.surface)
+
+ const ext = getPresetExt(layoutConfig)
+
+ // 1) motor principal
+ $t()
+ .preset(presetValue)
+ .preset(ext)
+ .surfacePalette(surfacePalette)
+ .use({ useDefaultOptions: true })
+
+ // 2) redundante/seguro
+ updatePreset(ext)
+ if (surfacePalette) updateSurfacePalette(surfacePalette)
+
+ return { presetValue, surfacePalette, ext }
+}
diff --git a/src/utils/dateBR.js b/src/utils/dateBR.js
new file mode 100644
index 0000000..d835f28
--- /dev/null
+++ b/src/utils/dateBR.js
@@ -0,0 +1,24 @@
+// src/utils/dateBR.js
+
+export function pad2(n) {
+ return String(n).padStart(2, '0')
+}
+
+// ISO (YYYY-MM-DD) -> BR (DD-MM-YYYY)
+export function isoToBR(iso) {
+ if (!iso) return ''
+ const s = String(iso).slice(0, 10)
+ const [y, m, d] = s.split('-')
+ if (!y || !m || !d) return ''
+ return `${pad2(d)}-${pad2(m)}-${y}`
+}
+
+// BR (DD-MM-YYYY) -> ISO (YYYY-MM-DD)
+export function brToISO(br) {
+ if (!br) return null
+ const s = String(br).trim()
+ const m = s.match(/^(\d{2})-(\d{2})-(\d{4})$/)
+ if (!m) return null
+ const [, dd, mm, yyyy] = m
+ return `${yyyy}-${mm}-${dd}`
+}
diff --git a/src/utils/slotsGenerator.js b/src/utils/slotsGenerator.js
new file mode 100644
index 0000000..232ab8c
--- /dev/null
+++ b/src/utils/slotsGenerator.js
@@ -0,0 +1,51 @@
+// src/utils/slotsGenerator.js
+
+function toMinutes(hhmm) {
+ const [h, m] = String(hhmm).slice(0, 5).split(':').map(Number)
+ return (h * 60) + m
+}
+function toHHMM(min) {
+ const h = Math.floor(min / 60)
+ const m = min % 60
+ return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
+}
+
+/**
+ * Gera lista de horários (HH:MM) a partir:
+ * - janelas: [{hora_inicio:'08:00', hora_fim:'12:00', ativo:true}]
+ * - regra: {passo_minutos:60, offset_minutos:30, ativo:true}
+ *
+ * Retorna horários de INÍCIO (igual Altegio)
+ */
+export function gerarSlotsDoDia(janelas, regra) {
+ const passo = Number(regra?.passo_minutos || 60)
+ const offset = Number(regra?.offset_minutos || 0)
+
+ const ativos = (janelas || []).filter(j => j?.ativo !== false)
+
+ const out = []
+
+ for (const j of ativos) {
+ const start = toMinutes(j.hora_inicio)
+ const end = toMinutes(j.hora_fim)
+
+ // encontra o primeiro t >= start que respeita offset
+ // condição: t % passo == offset (mod passo), mas offset é dentro da hora.
+ // Implementação simples: alinhar pelo minuto do dia:
+ // alvo: t ≡ offset (mod passo) quando offset é interpretado no ciclo do passo.
+ // Para seu uso (passo 60, offset 30), funciona perfeito.
+ let t = start
+ const mod = ((t % passo) + passo) % passo
+ const need = ((offset - mod) + passo) % passo
+ t = t + need
+
+ while (t + 1 <= end) {
+ // só inclui se dentro do intervalo
+ if (t >= start && t < end) out.push(toHHMM(t))
+ t += passo
+ }
+ }
+
+ // unique + sort
+ return Array.from(new Set(out)).sort()
+}
diff --git a/src/utils/upgradeContext.js b/src/utils/upgradeContext.js
new file mode 100644
index 0000000..32ae4aa
--- /dev/null
+++ b/src/utils/upgradeContext.js
@@ -0,0 +1,58 @@
+// src/utils/upgradeContext.js
+
+/**
+ * Parse "missing" query param into array of unique feature keys.
+ * Accepts:
+ * - "feature_a"
+ * - "feature_a,feature_b"
+ * - ["feature_a", "feature_b"] (vue-router pode entregar array)
+ */
+export function parseMissingKeys (missing) {
+ if (!missing) return []
+
+ const raw = Array.isArray(missing) ? missing.join(',') : String(missing)
+ const keys = raw
+ .split(',')
+ .map(s => s.trim())
+ .filter(Boolean)
+
+ // unique preserving order
+ const seen = new Set()
+ const unique = []
+ for (const k of keys) {
+ if (!seen.has(k)) {
+ seen.add(k)
+ unique.push(k)
+ }
+ }
+ return unique
+}
+
+/**
+ * Parse redirect param. Only allow internal app paths to avoid open-redirect.
+ * - Must start with "/"
+ * - Must NOT start with "//"
+ */
+export function parseRedirectTo (redirect) {
+ if (!redirect) return null
+ const s = Array.isArray(redirect) ? redirect[0] : String(redirect)
+ const trimmed = s.trim()
+ if (!trimmed) return null
+ if (!trimmed.startsWith('/')) return null
+ if (trimmed.startsWith('//')) return null
+ return trimmed
+}
+
+/**
+ * Build /upgrade URL with missing feature and redirect target.
+ */
+export function buildUpgradeUrl ({ missingKeys = [], redirectTo = null } = {}) {
+ const keys = Array.isArray(missingKeys) ? missingKeys.filter(Boolean) : []
+ const q = new URLSearchParams()
+
+ if (keys.length) q.set('missing', keys.join(','))
+ if (redirectTo) q.set('redirect', redirectTo)
+
+ const qs = q.toString()
+ return qs ? `/upgrade?${qs}` : '/upgrade'
+}
diff --git a/src/views/pages/Crud.vue b/src/views/pages/Crud.vue
index 772e6af..3dce360 100644
--- a/src/views/pages/Crud.vue
+++ b/src/views/pages/Crud.vue
@@ -1,5 +1,5 @@
+
+
+
+
+
+ Verificando sessão…
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Agência PSI
+
+
+ Ambiente de acesso e testes de perfis
+
+
+
+
+
+ Dev Mode
+
+
+
+
+
+
+
+
+
+
+ Sessão ativa
+
+
+ {{ userEmail }} — perfil: {{ role }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Admin
+
+
+
+
+
+ Gestão da clínica, controle de usuários, permissões, planos e configurações globais.
+
+
+
+ Acessar painel →
+
+
+
+
+
+
+
+
+
+ Terapeuta
+
+
+
+
+
+ Agenda, prontuários, evolução clínica, gestão de pacientes e atendimentos.
+
+
+
+ Acessar painel →
+
+
+
+
+
+
+
+
+
+ Paciente
+
+
+
+
+
+ Visualização de informações pessoais, documentos e interações com a clínica.
+
+
+
+ Acessar painel →
+
+
+
+
+
+
+
+
+ Você será redirecionado para o login (se necessário) e, após autenticação,
+ encaminhado automaticamente ao painel correspondente.
+
+
+
+
+
+
+
+
+ Ambiente de desenvolvimento
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/views/pages/NotFound.vue b/src/views/pages/NotFound.vue
index f7f7650..2e3edeb 100644
--- a/src/views/pages/NotFound.vue
+++ b/src/views/pages/NotFound.vue
@@ -1,63 +1,121 @@
-
-
+ A sessão é validada e o vínculo com a clínica define sua área.
+
+
+
+
+
+
+
+ Como funciona:
+ você autentica, o sistema carrega seu tenant ativo e só então libera o painel correspondente.
+ Isso evita acesso “solto” e organiza permissões no lugar certo.
+
+
+
+
+
+
+ Recuperação de senha via link (e-mail).
+
+
+
+ Se o link não chegar, cheque spam/lixo eletrônico.
+
+
+
+ O redirecionamento depende da role ativa: admin/therapist/patient.
+
+
+
+
+
+ Garanta que o Supabase tenha Redirect URLs incluindo
+ /auth/reset-password.
+
+ Escolha uma nova senha para sua conta. Depois, você fará login novamente.
+
+
+
+
+ {{ bannerText }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Nova senha
+
+ Mínimo: 8 caracteres, maiúscula, minúscula e número.
+
+
+
+
+
+
+
+
+
+
+
+ Dica: use uma frase curta + número (ex.: “NoiteCalma7”).
+
+
+ {{ strengthOk ? 'Senha forte o suficiente.' : 'Ainda está fraca — ajuste os critérios.' }}
+
+
+
+
+
+
+
+
+
+
+
Confirmar nova senha
+
+ Evita erro de digitação.
+
+
+
+
+
+
+
+
+
+
+
+ Digite novamente para confirmar.
+
+
+ {{ matchOk ? 'Confere.' : 'Não confere com a nova senha.' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Se você não solicitou essa redefinição, ignore o e-mail e faça logout em dispositivos desconhecidos.
+
+ Sua conta foi criada e a sua intenção de assinatura foi registrada.
+ Agora o caminho é simples: instruções de pagamento → confirmação → ativação do plano.
+
+
+
+
+
+
1) Pagamento
+
Manual
+
PIX ou boleto
+
+
+
+
+
2) Confirmação
+
Rápida
+
verificação e liberação
+
+
+
+
+
+
+
3) Plano ativo
+
Recursos liberados
+
entitlements PRO quando pago
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ * Página de boas-vindas inspirada em layouts PrimeBlocks.
+
+
+
+
+
+
+
Conta criada 🎉
+
+ Você já pode entrar. Se o seu plano for PRO, ele será ativado após confirmação do pagamento.
+
+
+
+
+ Sua intenção de assinatura foi registrada.
+
+
+
+ Esse recurso depende do plano que inclui a feature {{ requestedFeature }}.
+
+
+
+
+
+
+
+
+
+ A diferença entre “ter uma agenda” e “ter um sistema” mora nos detalhes.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Plano {{ String(p.key || '').toUpperCase() }}
+
+
+
+
+
+
+
+
+
+
+ O essencial para começar, sem travar seu fluxo.
+ Para quem quer automatizar, reduzir ruído e ganhar previsibilidade.
+ Plano personalizado: {{ p.key }}
+
+
+
+
+
+
+
+
+ {{ b.text }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cancele quando quiser. Sem burocracia.
+
+
+
+
+
+
+
+
+
+
+ Observação: alguns recursos PRO podem depender de configuração inicial (ex.: SMS exige provedor).
+
+ Uma agenda inteligente, um prontuário organizado, um financeiro respirável.
+
+
+
+ Centralize a rotina clínica em um lugar só: pacientes, sessões, lembretes e indicadores. Menos dispersão.
+ Mais presença.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Painel de hoje
+
Um recorte: o essencial, sem excesso.
+
+
+
+
+
+
+
+
+
+
Sessões
+
6
+
com lembretes automáticos
+
+
+
+
+
+
Recebimentos
+
R$ 840
+
visão clara do mês
+
+
+
+
+
+
+
+
Prontuário
+
Anotações e histórico
+
+ organizado por paciente, sessão e linha do tempo
+
+
+
+
+
+
+
+
+
+ * Ilustração conceitual do produto.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Agenda e autoagendamento
+
+ O paciente confirma, agenda e reagenda com autonomia (PRO).
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Financeiro integrado
+
+ Receita/despesa junto da agenda — sem planilhas espalhadas.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Prontuário e controle de sessões
+
+ Registro clínico e histórico acessíveis, com backups e organização.
+
+
+
+
+
+
+
+
+
+ Inspirações de módulos comuns no mercado: agenda online, financeiro, prontuário/controle de sessões e gestão de
+ clínica.
+
+
+
+
+
+
+
+
Recursos que sustentam a rotina
+
+ O foco é tirar o excesso de fricção sem invadir o que é do seu método.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ f.title }}
+
+ {{ f.desc }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Você abre a agenda, a sessão acontece, o registro fica no prontuário, e o financeiro acompanha o movimento.
+ O sistema existe para manter o consultório respirando — não para virar uma burocracia nova.
+
+
+
+
+ Perfis por função, agendas separadas, repasses e visão gerencial — quando você estiver pronto para crescer.
+
+
+
+
+ Controle de acesso por conta, separação por clínica/tenant, e políticas de storage por usuário. (Os detalhes
+ de conformidade você pode expor numa página própria de segurança/LGPD.)
+
+
+
+
+
+
+
+
Planos
+
+ Comece simples. Suba para PRO quando a agenda pedir automação.
+
+
+
+ Benefícios ainda não cadastrados para esse plano.
+
+
+
+
+
+ Não encontrei esse plano na vitrine pública. Você ainda pode criar a conta normalmente.
+
+
+
+
+
+
+ Você está criando a conta sem seleção de plano.
+
+
+
+
+
+
+ Esse plano não tem preço configurado para {{ intervalLabel }}. Você ainda pode criar a conta normalmente.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
or
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Ao criar a conta, registramos sua intenção de assinatura. Pagamento é manual (PIX/boleto) por enquanto.
+