Agenda, Agendador, Configurações

This commit is contained in:
Leonardo
2026-03-12 08:58:36 -03:00
parent f733db8436
commit f4b185ae17
197 changed files with 33405 additions and 6507 deletions
+79 -63
View File
@@ -8,6 +8,7 @@ import { useTenantStore } from '@/stores/tenantStore'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
import { buildUpgradeUrl } from '@/utils/upgradeContext'
import { logGuard, logError, logPerf } from '@/support/supportLogger'
import { useMenuStore } from '@/stores/menuStore'
import { getMenuByRole } from '@/navigation'
@@ -24,6 +25,10 @@ let sessionUidCache = null
let saasAdminCacheUid = null
let saasAdminCacheIsAdmin = null
// cache de globalRole por uid (evita query ao banco em cada navegação)
let globalRoleCacheUid = null
let globalRoleCache = null
// -----------------------------------------
// Pending invite (Modelo B) — retomada pós-login
// -----------------------------------------
@@ -94,7 +99,7 @@ function sleep (ms) {
async function waitSessionIfRefreshing () {
if (!sessionReady.value) {
try { await initSession({ initial: true }) } catch (e) {
console.warn('[guards] initSession falhou:', e)
logGuard('[guards] initSession falhou', { error: e?.message })
}
}
@@ -146,7 +151,7 @@ async function loadEntitlementsSafe (ent, tenantId, 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)
logGuard('[guards] ent.loadForTenant force fallback', { error: e?.message })
await ent.loadForTenant(tenantId, { force: true })
return
}
@@ -163,7 +168,7 @@ async function fetchTenantFeaturesSafe (tf, tenantId) {
try {
await tf.fetchForTenant(tenantId, { force: false })
} catch (e) {
console.warn('[guards] tf.fetchForTenant(force:false) falhou, tentando force:true', e)
logGuard('[guards] tf.fetchForTenant force fallback', { error: e?.message })
await tf.fetchForTenant(tenantId, { force: true })
}
}
@@ -234,9 +239,7 @@ async function ensureMenuBuilt ({ uid, tenantId, tenantRole, globalRole }) {
}
// cache com key igual mas menu errado: força rebuild
console.warn('[ensureMenuBuilt] key match mas menu incompatível com role, forçando rebuild:', {
key, safeRole, firstLabel
})
logGuard('[ensureMenuBuilt] menu incompatível com role, forçando rebuild', { key, safeRole, firstLabel })
menuStore.reset()
}
@@ -267,7 +270,7 @@ async function ensureMenuBuilt ({ uid, tenantId, tenantRole, globalRole }) {
const model = getMenuByRole(roleForMenu, ctx) || []
menuStore.setMenu(key, model)
} catch (e) {
console.warn('[guards] ensureMenuBuilt failed:', e)
logGuard('[guards] ensureMenuBuilt failed', { error: e?.message })
}
}
@@ -277,7 +280,7 @@ export function applyGuards (router) {
router.beforeEach(async (to) => {
const tlabel = `[guard] ${to.fullPath}`
console.time(tlabel)
const _perfEnd = logPerf('router.guard', tlabel)
try {
// ==========================================
@@ -285,7 +288,7 @@ export function applyGuards (router) {
// (ordem importa: /auth antes de meta.public)
// ==========================================
if (to.path.startsWith('/auth')) {
console.timeEnd(tlabel)
_perfEnd()
return true
}
@@ -293,25 +296,25 @@ export function applyGuards (router) {
// ✅ Rotas públicas
// ==========================================
if (to.meta?.public) {
console.timeEnd(tlabel)
_perfEnd()
return true
}
// se rota não exige auth, libera
if (!to.meta?.requiresAuth) {
console.timeEnd(tlabel)
_perfEnd()
return true
}
// não decide nada no meio do refresh do session.js
console.timeLog(tlabel, 'waitSessionIfRefreshing')
logGuard('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)
_perfEnd()
return { path: '/auth/login' }
}
@@ -321,21 +324,32 @@ export function applyGuards (router) {
to.path.startsWith('/supervisor')
// ======================================
// ✅ IDENTIDADE GLOBAL (1x por navegação)
// ✅ IDENTIDADE GLOBAL (cached por uid — sem query a cada navegação)
// - se falhar, NÃO nega por engano: volta pro login (seguro)
// ======================================
const { data: prof, error: profErr } = await supabase
.from('profiles')
.select('role')
.eq('id', uid)
.single()
let globalRole = null
const globalRole = !profErr ? prof?.role : null
console.timeLog(tlabel, 'profiles.role =', globalRole)
if (globalRoleCacheUid === uid && globalRoleCache) {
globalRole = globalRoleCache
logGuard('profiles.role (cache) =', globalRole)
} else {
const { data: prof, error: profErr } = await supabase
.from('profiles')
.select('role')
.eq('id', uid)
.single()
globalRole = !profErr ? prof?.role : null
if (globalRole) {
globalRoleCacheUid = uid
globalRoleCache = globalRole
}
logGuard('profiles.role (db) =', globalRole)
}
if (!globalRole) {
sessionStorage.setItem('redirect_after_login', to.fullPath)
console.timeEnd(tlabel)
_perfEnd()
return { path: '/auth/login' }
}
@@ -350,7 +364,7 @@ export function applyGuards (router) {
localStorage.removeItem('currentTenantId')
} catch (_) {}
console.timeEnd(tlabel)
_perfEnd()
return { path: '/portal' }
}
@@ -359,7 +373,7 @@ export function applyGuards (router) {
// ======================================
if (to.meta?.profileRole) {
if (globalRole !== to.meta.profileRole) {
console.timeEnd(tlabel)
_perfEnd()
return { path: '/pages/access' }
}
@@ -371,7 +385,7 @@ export function applyGuards (router) {
globalRole
})
console.timeEnd(tlabel)
_perfEnd()
return true
}
@@ -382,7 +396,7 @@ export function applyGuards (router) {
// ======================================
const isAccountArea = (to.path === '/account' || to.path.startsWith('/account/'))
if (isAccountArea) {
console.timeEnd(tlabel)
_perfEnd()
return true
}
@@ -410,7 +424,7 @@ export function applyGuards (router) {
isUuid(pendingInviteToken) &&
!to.path.startsWith('/accept-invite')
) {
console.timeEnd(tlabel)
_perfEnd()
return { path: '/accept-invite', query: { token: pendingInviteToken } }
}
@@ -419,6 +433,8 @@ export function applyGuards (router) {
sessionUidCache = uid
saasAdminCacheUid = null
saasAdminCacheIsAdmin = null
globalRoleCacheUid = null
globalRoleCache = null
const ent0 = useEntitlementsStore()
if (typeof ent0.invalidate === 'function') ent0.invalidate()
@@ -436,10 +452,10 @@ export function applyGuards (router) {
// ✅ SAAS MASTER: não depende de tenant
// ================================
if (to.meta?.saasAdmin) {
console.timeLog(tlabel, 'isSaasAdmin')
logGuard('isSaasAdmin')
// usa identidade global primeiro (evita cache fantasma)
const ok = (globalRole === 'saas_admin') ? true : await isSaasAdmin(uid)
if (!ok) { console.timeEnd(tlabel); return { path: '/pages/access' } }
if (!ok) { _perfEnd(); return { path: '/pages/access' } }
// ✅ monta menu SaaS 1x (AppMenu lê do menuStore)
await ensureMenuBuilt({
@@ -449,7 +465,7 @@ export function applyGuards (router) {
globalRole
})
console.timeEnd(tlabel)
_perfEnd()
return true
}
@@ -472,7 +488,7 @@ export function applyGuards (router) {
}
if (!platformRoles.includes('editor')) {
console.timeEnd(tlabel)
_perfEnd()
return { path: '/pages/access' }
}
@@ -483,7 +499,7 @@ export function applyGuards (router) {
globalRole
})
console.timeEnd(tlabel)
_perfEnd()
return true
}
@@ -491,7 +507,7 @@ export function applyGuards (router) {
// 🚫 SaaS master: bloqueia tenant-app por padrão
// ✅ Mas libera rotas de DEMO em DEV (Sakai)
// ================================
console.timeLog(tlabel, 'saas.lockdown?')
logGuard('saas.lockdown?')
// 🔥 PATCH: aqui NÃO consulta isSaasAdmin — usa só identidade global
const isSaas = (globalRole === 'saas_admin')
@@ -515,13 +531,13 @@ export function applyGuards (router) {
globalRole
})
console.timeEnd(tlabel)
_perfEnd()
return true
}
// Fora de /saas (e não-demo), não pode
if (!isSaasArea) {
console.timeEnd(tlabel)
_perfEnd()
return { path: '/saas' }
}
@@ -540,7 +556,7 @@ export function applyGuards (router) {
// carrega tenant + role
const tenant = useTenantStore()
console.timeLog(tlabel, 'tenant.loadSessionAndTenant?')
logGuard('tenant.loadSessionAndTenant?')
if (!tenant.loaded && !tenant.loading) {
await tenant.loadSessionAndTenant()
}
@@ -548,7 +564,7 @@ export function applyGuards (router) {
// 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)
_perfEnd()
return { path: '/auth/login' }
}
@@ -576,12 +592,12 @@ export function applyGuards (router) {
// 🔥 race/sem vínculo: em área tenant, trate como login necessário (não AccessDenied)
if (isTenantArea) {
sessionStorage.setItem('redirect_after_login', to.fullPath)
console.timeEnd(tlabel)
_perfEnd()
return { path: '/auth/login' }
}
if (to.path === '/pages/access') { console.timeEnd(tlabel); return true }
console.timeEnd(tlabel)
if (to.path === '/pages/access') { _perfEnd(); return true }
_perfEnd()
return { path: '/pages/access' }
}
@@ -596,14 +612,14 @@ export function applyGuards (router) {
// 🔥 FIX: se ainda assim não resolveu tenant/role e estamos em tenant area, não negue “por engano”
if (isTenantArea && (!tenant.activeTenantId || !tenant.activeRole)) {
sessionStorage.setItem('redirect_after_login', to.fullPath)
console.timeEnd(tlabel)
_perfEnd()
return { path: '/auth/login' }
}
let tenantId = tenant.activeTenantId
if (!tenantId) {
if (to.path === '/pages/access') { console.timeEnd(tlabel); return true }
console.timeEnd(tlabel)
if (to.path === '/pages/access') { _perfEnd(); return true }
_perfEnd()
return { path: '/pages/access' }
}
@@ -630,7 +646,7 @@ export function applyGuards (router) {
const desiredTenantId = desired?.tenant_id || null
if (desiredTenantId && tenant.activeTenantId !== desiredTenantId) {
console.timeLog(tlabel, `tenantScope.switch(${scope})`)
logGuard(`tenantScope.switch(${scope})`)
// ✅ guarda o tenant antigo para invalidar APENAS ele
const oldTenantId = tenant.activeTenantId
@@ -661,13 +677,9 @@ export function applyGuards (router) {
if (typeof menuStore.reset === 'function') menuStore.reset()
} catch {}
} else if (!desiredTenantId) {
console.warn('[guards] tenantScope sem match:', scope, {
memberships: mem.map(x => ({
tenant_id: x?.tenant_id,
role: x?.role,
kind: x?.kind,
status: x?.status
}))
logGuard('[guards] tenantScope sem match', {
scope,
memberships: mem.map(x => ({ tenant_id: x?.tenant_id, role: x?.role, kind: x?.kind, status: x?.status }))
})
}
}
@@ -682,7 +694,7 @@ export function applyGuards (router) {
// entitlements (✅ carrega só quando precisa)
const ent = useEntitlementsStore()
if (shouldLoadEntitlements(ent, tenantId)) {
console.timeLog(tlabel, 'ent.loadForTenant')
logGuard('ent.loadForTenant')
await loadEntitlementsSafe(ent, tenantId, true)
}
@@ -691,9 +703,9 @@ export function applyGuards (router) {
// user entitlements: therapist e supervisor têm assinatura pessoal (v_user_entitlements)
const activeRoleNormForEnt = normalizeRole(tenant.activeRole)
if (['therapist', 'supervisor'].includes(activeRoleNormForEnt) && uid && ent.loadedForUser !== uid) {
console.timeLog(tlabel, 'ent.loadForUser')
logGuard('ent.loadForUser')
try { await ent.loadForUser(uid) } catch (e) {
console.warn('[guards] ent.loadForUser failed:', e)
logGuard('[guards] ent.loadForUser failed', { error: e?.message })
}
}
@@ -704,7 +716,7 @@ export function applyGuards (router) {
const requiredTenantFeature = to.meta?.tenantFeature
if (requiredTenantFeature) {
const tf = useTenantFeaturesStore()
console.timeLog(tlabel, 'tenantFeatures.fetchForTenant')
logGuard('tenantFeatures.fetchForTenant')
await fetchTenantFeaturesSafe(tf, tenantId)
// ✅ IMPORTANTÍSSIMO: passa tenantId
@@ -713,9 +725,9 @@ export function applyGuards (router) {
: false
if (!enabled) {
if (to.path === '/admin/clinic/features') { console.timeEnd(tlabel); return true }
if (to.path === '/admin/clinic/features') { _perfEnd(); return true }
console.timeEnd(tlabel)
_perfEnd()
return {
path: '/admin/clinic/features',
query: { missing: requiredTenantFeature, redirectTo: to.fullPath }
@@ -746,7 +758,7 @@ export function applyGuards (router) {
if (compatible) {
tenant.activeRole = normalizeRole(compatible.role, compatible.kind)
} else {
console.timeEnd(tlabel)
_perfEnd()
return denyByRole({ to, currentRole: tenant.activeRole })
}
}
@@ -756,7 +768,7 @@ export function applyGuards (router) {
const requiredRole = requiredRoleRaw ? normalizeRole(requiredRoleRaw) : null
if (requiredRole && normalizeRole(tenant.activeRole) !== requiredRole) {
console.timeEnd(tlabel)
_perfEnd()
return denyByRole({ to, currentRole: tenant.activeRole })
}
@@ -765,7 +777,7 @@ export function applyGuards (router) {
// ------------------------------------------------
const requiredFeature = to.meta?.feature
if (requiredFeature && ent?.can && !ent.can(requiredFeature)) {
if (to.name === 'upgrade') { console.timeEnd(tlabel); return true }
if (to.name === 'upgrade') { _perfEnd(); return true }
const url = buildUpgradeUrl({
missingKeys: [requiredFeature],
@@ -773,7 +785,7 @@ export function applyGuards (router) {
role: normalizeRole(tenant.activeRole) // ✅ passa o role para a UpgradePage detectar
})
console.timeEnd(tlabel)
_perfEnd()
return url
}
@@ -787,10 +799,10 @@ export function applyGuards (router) {
globalRole
})
console.timeEnd(tlabel)
_perfEnd()
return true
} catch (e) {
console.error('[guards] erro no beforeEach:', e)
logError('router.guard', 'erro no beforeEach', e)
if (to.path.startsWith('/auth')) return true
if (to.meta?.public) return true
@@ -814,6 +826,8 @@ export function applyGuards (router) {
sessionUidCache = null
saasAdminCacheUid = null
saasAdminCacheIsAdmin = null
globalRoleCacheUid = null
globalRoleCache = null
// ✅ FIX: limpa o localStorage de tenant na saída
// Sem isso, o próximo login restaura o tenant do usuário anterior.
@@ -861,6 +875,8 @@ export function applyGuards (router) {
sessionUidCache = uid || null
saasAdminCacheUid = null
saasAdminCacheIsAdmin = null
globalRoleCacheUid = null
globalRoleCache = null
try {
const tf = useTenantFeaturesStore()
-2
View File
@@ -5,7 +5,6 @@ import meRoutes from './routes.account';
import adminRoutes from './routes.clinic';
import authRoutes from './routes.auth';
import billingRoutes from './routes.billing';
import demoRoutes from './routes.demo';
import miscRoutes from './routes.misc';
import portalRoutes from './routes.portal';
import publicRoutes from './routes.public';
@@ -29,7 +28,6 @@ const routes = [
...(Array.isArray(supervisorRoutes) ? supervisorRoutes : [supervisorRoutes]),
...(Array.isArray(editorRoutes) ? editorRoutes : [editorRoutes]),
...(Array.isArray(portalRoutes) ? portalRoutes : [portalRoutes]),
...(Array.isArray(demoRoutes) ? demoRoutes : [demoRoutes]),
...(Array.isArray(configuracoesRoutes) ? configuracoesRoutes : [configuracoesRoutes]),
...(Array.isArray(featuresRoutes) ? featuresRoutes : [featuresRoutes]),
+20
View File
@@ -65,6 +65,14 @@ export default {
}
},
// Recorrências
{
path: 'agenda/recorrencias',
name: 'admin-agenda-recorrencias',
component: () => import('@/features/agenda/pages/AgendaRecorrenciasPage.vue'),
meta: { feature: 'agenda.view', roles: ['clinic_admin', 'tenant_admin'], mode: 'clinic' }
},
// ✅ NOVO: Compromissos determinísticos (tipos)
{
path: 'agenda/compromissos',
@@ -172,6 +180,18 @@ export default {
meta: {
feature: 'online_scheduling.manage'
}
},
// ======================================================
// 🔒 PRO — Agendamentos Recebidos
// ======================================================
{
path: 'agendamentos-recebidos',
name: 'admin-agendamentos-recebidos',
component: () => import('@/features/agenda/pages/AgendamentosRecebidosPage.vue'),
meta: {
feature: 'online_scheduling.manage'
}
}
]
}
+20
View File
@@ -21,6 +21,26 @@ const configuracoesRoutes = {
path: 'agenda',
name: 'ConfiguracoesAgenda',
component: () => import('@/layout/configuracoes/ConfiguracoesAgendaPage.vue')
},
{
path: 'bloqueios',
name: 'ConfiguracoesBloqueios',
component: () => import('@/layout/configuracoes/BloqueiosPage.vue')
},
{
path: 'agendador',
name: 'ConfiguracoesAgendador',
component: () => import('@/layout/configuracoes/ConfiguracoesAgendadorPage.vue')
},
{
path: 'pagamento',
name: 'ConfiguracoesPagamento',
component: () => import('@/layout/configuracoes/ConfiguracoesPagamentoPage.vue')
},
{
path: 'precificacao',
name: 'ConfiguracoesPrecificacao',
component: () => import('@/layout/configuracoes/ConfiguracoesPrecificacaoPage.vue')
}
]
}
-34
View File
@@ -1,34 +0,0 @@
import AppLayout from '@/layout/AppLayout.vue'
export default {
// ✅ não use '/' aqui (conflita com HomeCards)
path: '/demo',
component: AppLayout,
// ✅ DEMO pertence ao backoffice SaaS (somente DEV)
// - assim o guard trata como área SaaS e não cai no tenant-app
// - remove dependência de role tenant_admin / tenant ativo
meta: { requiresAuth: true, saasAdmin: true },
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') }
]
}
+8 -1
View File
@@ -28,6 +28,13 @@ export default {
name: 'accept-invite',
component: () => import('@/views/pages/public/AcceptInvitePage.vue'),
meta: { public: true }
}
},
// ✅ agendador online público
{
path: '/agendar/:slug',
name: 'agendador.publico',
component: () => import('@/views/pages/public/AgendadorPublicoPage.vue'),
meta: { public: true }
}
]
};
+21
View File
@@ -60,6 +60,27 @@ export default {
path: 'tenants',
name: 'saas-tenants',
component: () => import('@/views/pages/saas/SaasPlaceholder.vue')
},
{
path: 'feriados',
name: 'saas-feriados',
component: () => import('@/views/pages/saas/SaasFeriadosPage.vue')
},
{
path: 'docs',
name: 'saas-docs',
component: () => import('@/views/pages/saas/SaasDocsPage.vue')
},
{
path: 'faq',
name: 'saas-faq',
component: () => import('@/views/pages/saas/SaasFaqPage.vue')
},
{
path: 'support',
name: 'saas-support',
component: () => import('@/views/pages/saas/SaasSupportPage.vue'),
meta: { requiresAuth: true, saasAdmin: true }
}
]
}
+30
View File
@@ -25,6 +25,14 @@ export default {
}
},
// Recorrências
{
path: 'agenda/recorrencias',
name: 'therapist-agenda-recorrencias',
component: () => import('@/features/agenda/pages/AgendaRecorrenciasPage.vue'),
meta: { feature: 'agenda.view', mode: 'therapist' }
},
// ✅ Compromissos determinísticos
{
path: 'agenda/compromissos',
@@ -103,6 +111,28 @@ export default {
}
},
// ======================================================
// 🔒 PRO — Agendamentos Recebidos
// ======================================================
{
path: 'agendamentos-recebidos',
name: 'therapist-agendamentos-recebidos',
component: () => import('@/features/agenda/pages/AgendamentosRecebidosPage.vue'),
meta: {
feature: 'online_scheduling.manage'
}
},
// ======================================================
// 📈 RELATÓRIOS
// ======================================================
{
path: 'relatorios',
name: 'therapist-relatorios',
component: () => import('@/views/pages/therapist/RelatoriosPage.vue'),
meta: { feature: 'agenda.view' }
},
// ======================================================
// 🔐 SECURITY
// ======================================================