carousel, agenda arquivados, agenda cor, agenda arquivados, grupos pacientes, pacientes arquivados - desativados, sessoes verificadas, ajuste notificações, Prontuario, Agenda Animation, Menu Profile, bagdes Profile, Offline
This commit is contained in:
@@ -17,7 +17,7 @@ const { layoutState } = useLayout()
|
||||
const menuStore = useMenuStore()
|
||||
|
||||
// ======================================================
|
||||
// ✅ Blindagem anti-“menu some”
|
||||
// ✅ Blindagem anti-"menu some"
|
||||
// - se o menuStore.model piscar como [], mantém o último menu válido
|
||||
// - evita sumiço ao entrar em /admin/clinic/features (reset momentâneo)
|
||||
// ======================================================
|
||||
|
||||
+148
-137
@@ -6,20 +6,17 @@ import Popover from 'primevue/popover'
|
||||
|
||||
import { sessionUser, sessionRole } from '@/app/session'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
import { useRoleGuard } from '@/composables/useRoleGuard'
|
||||
|
||||
const router = useRouter()
|
||||
const pop = ref(null)
|
||||
const props = defineProps({
|
||||
variant: { type: String, default: 'sidebar' }
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const pop = ref(null)
|
||||
|
||||
// ------------------------------------------------------
|
||||
// RBAC (Tenant): fonte da verdade para permissões por papel
|
||||
// ------------------------------------------------------
|
||||
const { role, canSee } = useRoleGuard()
|
||||
|
||||
// ------------------------------------------------------
|
||||
// UI labels (nome/iniciais)
|
||||
// ------------------------------------------------------
|
||||
const initials = computed(() => {
|
||||
const name = sessionUser.value?.user_metadata?.full_name || sessionUser.value?.email || ''
|
||||
const parts = String(name).trim().split(/\s+/).filter(Boolean)
|
||||
@@ -33,167 +30,181 @@ const label = computed(() => {
|
||||
return name || sessionUser.value?.email || 'Conta'
|
||||
})
|
||||
|
||||
/**
|
||||
* sublabel:
|
||||
* Prefere exibir o papel do TENANT (role do useRoleGuard),
|
||||
* porque governa a UI dentro da clínica.
|
||||
*/
|
||||
const sublabel = computed(() => {
|
||||
const r = role.value || sessionRole.value
|
||||
if (!r) return 'Sessão'
|
||||
|
||||
// tenant roles
|
||||
if (r === 'clinic_admin' || r === 'tenant_admin' || r === 'admin') return 'Administrador'
|
||||
if (r === 'therapist') return 'Terapeuta'
|
||||
|
||||
// portal/global roles
|
||||
if (r === 'portal_user') return 'Portal'
|
||||
if (r === 'patient') return 'Portal' // legado (caso ainda exista em algum lugar)
|
||||
|
||||
if (r === 'portal_user' || r === 'patient') return 'Portal'
|
||||
return r
|
||||
})
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Popover helpers
|
||||
// ------------------------------------------------------
|
||||
function toggle (e) {
|
||||
pop.value?.toggle(e)
|
||||
}
|
||||
const avatarUrl = computed(() => sessionUser.value?.user_metadata?.avatar_url || null)
|
||||
|
||||
function close () {
|
||||
try { pop.value?.hide() } catch {}
|
||||
}
|
||||
function toggle (e) { pop.value?.toggle(e) }
|
||||
function close () { try { pop.value?.hide() } catch {} }
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Navegação segura (resolve antes; fallback se não existir)
|
||||
// ------------------------------------------------------
|
||||
async function safePush (target, fallback) {
|
||||
try {
|
||||
const r = router.resolve(target)
|
||||
if (r?.matched?.length) return await router.push(target)
|
||||
} catch {}
|
||||
|
||||
if (fallback) {
|
||||
try { return await router.push(fallback) } catch {}
|
||||
}
|
||||
|
||||
if (fallback) { try { return await router.push(fallback) } catch {} }
|
||||
return router.push('/')
|
||||
}
|
||||
|
||||
// ------------------------------------------------------
|
||||
// Actions
|
||||
// ------------------------------------------------------
|
||||
function goMyProfile () {
|
||||
function goMyProfile () { close(); safePush({ name: 'account-profile' }, '/account/profile') }
|
||||
function goSecurity () { close(); safePush({ name: 'account-security' }, '/account/security') }
|
||||
function goSettings () {
|
||||
close()
|
||||
safePush({ name: 'account-profile' }, '/account/profile')
|
||||
}
|
||||
|
||||
function goSettings () {
|
||||
close()
|
||||
|
||||
// ✅ Configurações é RBAC (quem pode ver, vê)
|
||||
if (canSee('settings.view')) {
|
||||
return safePush({ name: 'ConfiguracoesAgenda' }, '/admin/settings') // fallback genérico
|
||||
}
|
||||
|
||||
// ✅ quem não pode (ex.: paciente), manda pro portal correto
|
||||
if (canSee('settings.view')) return safePush({ name: 'ConfiguracoesAgenda' }, '/admin/settings')
|
||||
return safePush({ name: 'portal-sessoes' }, '/portal')
|
||||
}
|
||||
|
||||
function goSecurity () {
|
||||
close()
|
||||
|
||||
// ✅ Segurança é "Account": todos podem acessar
|
||||
return safePush(
|
||||
{ name: 'account-security' },
|
||||
'/account/security'
|
||||
)
|
||||
}
|
||||
|
||||
async function signOut () {
|
||||
close()
|
||||
try {
|
||||
await supabase.auth.signOut()
|
||||
} catch {
|
||||
// se falhar, ainda assim manda pro login
|
||||
} finally {
|
||||
router.push('/auth/login')
|
||||
}
|
||||
try { await supabase.auth.signOut() } catch {}
|
||||
finally { router.push('/auth/login') }
|
||||
}
|
||||
|
||||
defineExpose({ toggle })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sticky bottom-0 z-20 border-t border-[var(--surface-border)] bg-[var(--surface-card)]">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-3 flex items-center gap-3 hover:bg-[var(--surface-ground)] transition"
|
||||
@click="toggle"
|
||||
>
|
||||
<!-- avatar -->
|
||||
<img
|
||||
v-if="sessionUser?.user_metadata?.avatar_url"
|
||||
:src="sessionUser.user_metadata.avatar_url"
|
||||
class="h-9 w-9 rounded-xl object-cover border border-[var(--surface-border)]"
|
||||
alt="avatar"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="h-9 w-9 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center text-sm font-semibold"
|
||||
<!-- ── SIDEBAR: trigger + popover ── -->
|
||||
<template v-if="variant === 'sidebar'">
|
||||
<div class="sticky bottom-0 z-20 border-t border-[var(--surface-border)] bg-[var(--surface-card)]">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-3 flex items-center gap-3 hover:bg-[var(--surface-ground)] transition-colors duration-150"
|
||||
@click="toggle"
|
||||
>
|
||||
{{ initials }}
|
||||
</div>
|
||||
|
||||
<!-- labels -->
|
||||
<div class="min-w-0 flex-1 text-left">
|
||||
<div class="truncate text-sm font-semibold text-[var(--text-color)]">
|
||||
{{ label }}
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="h-9 w-9 rounded-xl object-cover border border-[var(--surface-border)]" alt="avatar" />
|
||||
<div v-else class="h-9 w-9 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center text-sm font-semibold">{{ initials }}</div>
|
||||
<div class="min-w-0 flex-1 text-left">
|
||||
<div class="truncate text-sm font-semibold text-[var(--text-color)]">{{ label }}</div>
|
||||
<div class="truncate text-xs text-[var(--text-color-secondary)]">{{ sublabel }}</div>
|
||||
</div>
|
||||
<div class="truncate text-xs text-[var(--text-color-secondary)]">
|
||||
{{ sublabel }}
|
||||
</div>
|
||||
</div>
|
||||
<i class="pi pi-angle-up text-xs opacity-40" />
|
||||
</button>
|
||||
|
||||
<i class="pi pi-angle-up text-xs opacity-70" />
|
||||
</button>
|
||||
<Popover ref="pop" appendTo="body">
|
||||
<!-- conteúdo reutilizado via template inline -->
|
||||
<template v-if="true">
|
||||
<div class="w-[224px] overflow-hidden rounded-[inherit]">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-2.5 px-3.5 pt-3.5 pb-3 bg-[var(--primary-color)]/[0.06] border-b border-[var(--surface-border)]">
|
||||
<div class="relative shrink-0">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="w-9 h-9 rounded-[10px] object-cover ring-[1.5px] ring-[var(--primary-color)]/30" alt="avatar" />
|
||||
<div v-else class="w-9 h-9 rounded-[10px] grid place-items-center text-[1rem] font-bold text-[var(--primary-color)] bg-[var(--primary-color)]/10 ring-[1.5px] ring-[var(--primary-color)]/20">{{ initials }}</div>
|
||||
<span class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-green-500 border-2 border-[var(--surface-card)]" />
|
||||
</div>
|
||||
<div class="min-w-0 flex flex-col gap-px">
|
||||
<span class="text-[1rem] font-bold text-[var(--text-color)] truncate tracking-tight">{{ label }}</span>
|
||||
<span class="text-[0.85rem] text-[var(--text-color-secondary)] truncate opacity-70">{{ sessionUser?.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nav items -->
|
||||
<div class="py-1.5">
|
||||
<button
|
||||
class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150"
|
||||
@click="goMyProfile"
|
||||
>
|
||||
<i class="pi pi-user text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
|
||||
Meu perfil
|
||||
</button>
|
||||
<button
|
||||
class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150"
|
||||
@click="goSecurity"
|
||||
>
|
||||
<i class="pi pi-shield text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
|
||||
Segurança
|
||||
</button>
|
||||
<button
|
||||
v-if="canSee('settings.view')"
|
||||
class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150"
|
||||
@click="goSettings"
|
||||
>
|
||||
<i class="pi pi-cog text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
|
||||
Configurações
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Footer: Sair -->
|
||||
<div class="border-t border-[var(--surface-border)] py-1.5">
|
||||
<button
|
||||
class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-red-500 hover:bg-red-500/[0.06] hover:pl-4 transition-all duration-150"
|
||||
@click="signOut"
|
||||
>
|
||||
<i class="pi pi-sign-out text-[0.72rem] opacity-60 group-hover:opacity-100 transition-opacity duration-150" />
|
||||
Sair
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- ── RAIL: só o popover, trigger externo ── -->
|
||||
<template v-else>
|
||||
<Popover ref="pop" appendTo="body">
|
||||
<div class="min-w-[220px] p-1">
|
||||
<Button
|
||||
v-if="canSee('settings.view')"
|
||||
label="Configurações"
|
||||
icon="pi pi-cog"
|
||||
text
|
||||
class="w-full justify-start"
|
||||
@click="goSettings"
|
||||
/>
|
||||
<div class="w-[224px] overflow-hidden rounded-[inherit]">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center gap-2.5 px-3.5 pt-3.5 pb-3 bg-[var(--primary-color)]/[0.06] border-b border-[var(--surface-border)]">
|
||||
<div class="relative shrink-0">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="w-9 h-9 rounded-[10px] object-cover ring-[1.5px] ring-[var(--primary-color)]/30" alt="avatar" />
|
||||
<div v-else class="w-9 h-9 rounded-[10px] grid place-items-center text-[1rem] font-bold text-[var(--primary-color)] bg-[var(--primary-color)]/10 ring-[1.5px] ring-[var(--primary-color)]/20">{{ initials }}</div>
|
||||
<span class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-green-500 border-2 border-[var(--surface-card)]" />
|
||||
</div>
|
||||
<div class="min-w-0 flex flex-col gap-px">
|
||||
<span class="text-[1rem] font-bold text-[var(--text-color)] truncate tracking-tight">{{ label }}</span>
|
||||
<span class="text-[0.85rem] text-[var(--text-color-secondary)] truncate opacity-70">{{ sessionUser?.email }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
label="Segurança"
|
||||
icon="pi pi-shield"
|
||||
text
|
||||
class="w-full justify-start"
|
||||
@click="goSecurity"
|
||||
/>
|
||||
<!-- Nav items -->
|
||||
<div class="py-1.5">
|
||||
<button
|
||||
class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150"
|
||||
@click="goMyProfile"
|
||||
>
|
||||
<i class="pi pi-user text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
|
||||
Meu perfil
|
||||
</button>
|
||||
<button
|
||||
class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150"
|
||||
@click="goSecurity"
|
||||
>
|
||||
<i class="pi pi-shield text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
|
||||
Segurança
|
||||
</button>
|
||||
<button
|
||||
v-if="canSee('settings.view')"
|
||||
class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150"
|
||||
@click="goSettings"
|
||||
>
|
||||
<i class="pi pi-cog text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
|
||||
Configurações
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
label="Meu Perfil"
|
||||
icon="pi pi-user"
|
||||
text
|
||||
class="w-full justify-start"
|
||||
@click="goMyProfile"
|
||||
/>
|
||||
|
||||
<div class="my-1 border-t border-[var(--surface-border)]" />
|
||||
|
||||
<Button
|
||||
label="Sair"
|
||||
icon="pi pi-sign-out"
|
||||
severity="danger"
|
||||
text
|
||||
class="w-full justify-start"
|
||||
@click="signOut"
|
||||
/>
|
||||
<!-- Footer: Sair -->
|
||||
<div class="border-t border-[var(--surface-border)] py-1.5">
|
||||
<button
|
||||
class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-red-500 hover:bg-red-500/[0.06] hover:pl-4 transition-all duration-150"
|
||||
@click="signOut"
|
||||
>
|
||||
<i class="pi pi-sign-out text-[0.72rem] opacity-60 group-hover:opacity-100 transition-opacity duration-150" />
|
||||
Sair
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
/* Zero padding nativo do PrimeVue — mínimo inevitável pois não há prop pra isso */
|
||||
.p-popover-content { padding: 0 !important; }
|
||||
</style>
|
||||
@@ -8,6 +8,7 @@ import Popover from 'primevue/popover'
|
||||
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { useEntitlementsStore } from '@/stores/entitlementsStore'
|
||||
import { useMenuBadges } from '@/composables/useMenuBadges'
|
||||
|
||||
const { layoutState, isDesktop } = useLayout()
|
||||
const router = useRouter()
|
||||
@@ -15,6 +16,15 @@ const pop = ref(null)
|
||||
|
||||
const tenantStore = useTenantStore()
|
||||
const entitlementsStore = useEntitlementsStore()
|
||||
const menuBadges = useMenuBadges()
|
||||
|
||||
function menuBadgeLabel (item) {
|
||||
const key = item?.badgeKey
|
||||
if (!key) return null
|
||||
const val = menuBadges[key]?.value || 0
|
||||
if (!val) return null
|
||||
return key === 'agendaHoje' ? `${val} hoje` : String(val)
|
||||
}
|
||||
|
||||
const emit = defineEmits(['quick-create'])
|
||||
|
||||
@@ -102,7 +112,7 @@ const showProBadge = computed(() => {
|
||||
try {
|
||||
return !entitlementsStore.has(feature)
|
||||
} catch {
|
||||
// se der erro, não mostra (evita “PRO fantasma”)
|
||||
// se der erro, não mostra (evita "PRO fantasma")
|
||||
return false
|
||||
}
|
||||
})
|
||||
@@ -221,6 +231,14 @@ async function irCadastroCompleto () {
|
||||
PRO
|
||||
</span>
|
||||
|
||||
<!-- Badge contador (agenda hoje / cadastros / agendamentos) -->
|
||||
<span
|
||||
v-if="menuBadgeLabel(item)"
|
||||
class="ml-auto text-[0.62rem] font-bold px-1.5 py-px rounded-full bg-[var(--primary-color)] text-white leading-none"
|
||||
>
|
||||
{{ menuBadgeLabel(item) }}
|
||||
</span>
|
||||
|
||||
<i v-if="item.items" class="pi pi-fw pi-angle-down layout-submenu-toggler" />
|
||||
</component>
|
||||
|
||||
|
||||
+13
-50
@@ -1,19 +1,15 @@
|
||||
<!-- src/layout/AppRail.vue — Mini icon rail (Layout 2) -->
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import Popover from 'primevue/popover'
|
||||
import Button from 'primevue/button'
|
||||
import { useMenuStore } from '@/stores/menuStore'
|
||||
import { useLayout } from './composables/layout'
|
||||
import { sessionUser } from '@/app/session'
|
||||
|
||||
import { useMenuStore } from '@/stores/menuStore'
|
||||
import { useLayout } from './composables/layout'
|
||||
import { sessionUser } from '@/app/session'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import AppMenuFooterPanel from './AppMenuFooterPanel.vue'
|
||||
|
||||
const menuStore = useMenuStore()
|
||||
const { layoutConfig, layoutState, isDesktop } = useLayout()
|
||||
const router = useRouter()
|
||||
const { layoutState } = useLayout()
|
||||
|
||||
// ── Seções do rail (derivadas do model) ─────────────────────
|
||||
const railSections = computed(() => {
|
||||
@@ -38,7 +34,6 @@ const initials = computed(() => {
|
||||
const b = parts.length > 1 ? parts[parts.length - 1][0] : ''
|
||||
return (a + b).toUpperCase()
|
||||
})
|
||||
|
||||
const userName = computed(() => sessionUser.value?.user_metadata?.full_name || sessionUser.value?.email || 'Conta')
|
||||
|
||||
// ── Início (fixo) ────────────────────────────────────────────
|
||||
@@ -74,20 +69,9 @@ function isActiveSectionOrChild (section) {
|
||||
})
|
||||
}
|
||||
|
||||
// ── Popover do usuário (rodapé) ───────────────────────────────
|
||||
const userPop = ref(null)
|
||||
function toggleUserPop (e) { userPop.value?.toggle(e) }
|
||||
|
||||
function goTo (path) {
|
||||
try { userPop.value?.hide() } catch {}
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
async function signOut () {
|
||||
try { userPop.value?.hide() } catch {}
|
||||
try { await supabase.auth.signOut() } catch {}
|
||||
router.push('/auth/login')
|
||||
}
|
||||
// ── Menu do usuário (rodapé) ─────────────────────────────────
|
||||
const footerPanel = ref(null)
|
||||
function toggleUserMenu (e) { footerPanel.value?.toggle(e) }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -131,47 +115,26 @@ async function signOut () {
|
||||
class="w-9 h-9 rounded-[10px] grid place-items-center border-none bg-transparent text-[var(--text-color-secondary)] cursor-pointer text-[0.875rem] shrink-0 transition-[background,color,transform] duration-150 hover:bg-[var(--surface-ground)] hover:text-[var(--text-color)] hover:scale-105"
|
||||
v-tooltip.right="{ value: 'Configurações', showDelay: 0 }"
|
||||
aria-label="Configurações"
|
||||
@click="goTo('/configuracoes')"
|
||||
@click="$router.push('/configuracoes')"
|
||||
>
|
||||
<i class="pi pi-fw pi-cog" />
|
||||
</button>
|
||||
|
||||
<!-- Avatar / user -->
|
||||
<!-- Avatar — trigger do menu de usuário -->
|
||||
<button
|
||||
class="w-9 h-9 rounded-[10px] border-none cursor-pointer overflow-hidden shrink-0 bg-[var(--surface-ground)] grid place-items-center transition-[transform,box-shadow] duration-150 hover:scale-105 hover:shadow-[0_0_0_2px_var(--primary-color)]"
|
||||
v-tooltip.right="{ value: userName, showDelay: 0 }"
|
||||
:aria-label="userName"
|
||||
@click="toggleUserPop"
|
||||
@click="toggleUserMenu"
|
||||
>
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="w-full h-full object-cover" :alt="userName" />
|
||||
<span v-else class="text-[1rem] font-bold text-[var(--text-color)]">{{ initials }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ── Popover usuário ────────────────────────────────── -->
|
||||
<Popover ref="userPop" appendTo="body">
|
||||
<div class="min-w-[210px] p-1 flex flex-col gap-0.5">
|
||||
<div class="flex items-center gap-2.5 px-2.5 py-2 pb-2.5">
|
||||
<div class="w-9 h-9 rounded-[9px] overflow-hidden shrink-0 bg-[var(--surface-ground)] grid place-items-center border border-[var(--surface-border)]">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="w-full h-full object-cover" />
|
||||
<span v-else class="text-[1rem] font-bold text-[var(--text-color)]">{{ initials }}</span>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-[0.83rem] font-semibold text-[var(--text-color)] truncate">{{ userName }}</div>
|
||||
<div class="text-[0.68rem] text-[var(--text-color-secondary)] truncate">{{ sessionUser?.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ── Menu de usuário (popup via AppMenuFooterPanel) ── -->
|
||||
<AppMenuFooterPanel ref="footerPanel" variant="rail" />
|
||||
|
||||
<div class="h-px bg-[var(--surface-border)] my-0.5" />
|
||||
|
||||
<Button label="Meu Perfil" icon="pi pi-user" text class="w-full justify-start" @click="goTo('/account/profile')" />
|
||||
<Button label="Segurança" icon="pi pi-shield" text class="w-full justify-start" @click="goTo('/account/security')" />
|
||||
|
||||
<div class="h-px bg-[var(--surface-border)] my-0.5" />
|
||||
|
||||
<Button label="Sair" icon="pi pi-sign-out" severity="danger" text class="w-full justify-start" @click="signOut" />
|
||||
</div>
|
||||
</Popover>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -6,13 +6,23 @@ import { useRouter, useRoute } from 'vue-router'
|
||||
import { useMenuStore } from '@/stores/menuStore'
|
||||
import { useLayout } from './composables/layout'
|
||||
import { useEntitlementsStore } from '@/stores/entitlementsStore'
|
||||
import { useMenuBadges } from '@/composables/useMenuBadges'
|
||||
|
||||
const menuStore = useMenuStore()
|
||||
const { layoutState } = useLayout()
|
||||
const entitlements = useEntitlementsStore()
|
||||
const menuBadges = useMenuBadges()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
function menuBadgeLabel (item) {
|
||||
const key = item?.badgeKey
|
||||
if (!key) return null
|
||||
const val = menuBadges[key]?.value || 0
|
||||
if (!val) return null
|
||||
return key === 'agendaHoje' ? `${val} hoje` : String(val)
|
||||
}
|
||||
|
||||
// ── Seção ativa ──────────────────────────────────────────────
|
||||
const currentSection = computed(() => {
|
||||
const model = menuStore.model || []
|
||||
@@ -372,6 +382,7 @@ async function goToResult (r) {
|
||||
<i v-if="child.icon" :class="child.icon" class="text-[1rem] shrink-0 opacity-75" />
|
||||
<span class="flex-1">{{ child.label }}</span>
|
||||
<span v-if="isLocked(child)" class="text-[0.58rem] font-extrabold uppercase tracking-widest px-1.5 py-px rounded border border-[var(--surface-border)] text-[var(--text-color-secondary)] opacity-70">PRO</span>
|
||||
<span v-if="menuBadgeLabel(child)" class="text-[0.62rem] font-bold px-1.5 py-px rounded-full bg-[var(--primary-color)] text-white leading-none">{{ menuBadgeLabel(child) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -388,6 +399,7 @@ async function goToResult (r) {
|
||||
<i v-if="item.icon" :class="item.icon" class="text-[1rem] shrink-0 opacity-75" />
|
||||
<span class="flex-1">{{ item.label }}</span>
|
||||
<span v-if="isLocked(item)" class="text-[0.58rem] font-extrabold uppercase tracking-widest px-1.5 py-px rounded border border-[var(--surface-border)] text-[var(--text-color-secondary)] opacity-70">PRO</span>
|
||||
<span v-if="menuBadgeLabel(item)" class="text-[0.62rem] font-bold px-1.5 py-px rounded-full bg-[var(--primary-color)] text-white leading-none">{{ menuBadgeLabel(item) }}</span>
|
||||
</button>
|
||||
|
||||
</template>
|
||||
|
||||
@@ -6,13 +6,23 @@ import { useRouter, useRoute } from 'vue-router'
|
||||
import { useMenuStore } from '@/stores/menuStore'
|
||||
import { useLayout } from './composables/layout'
|
||||
import { useEntitlementsStore } from '@/stores/entitlementsStore'
|
||||
import { useMenuBadges } from '@/composables/useMenuBadges'
|
||||
|
||||
const menuStore = useMenuStore()
|
||||
const { layoutState, hideMobileMenu } = useLayout()
|
||||
const entitlements = useEntitlementsStore()
|
||||
const menuBadges = useMenuBadges()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
function menuBadgeLabel (item) {
|
||||
const key = item?.badgeKey
|
||||
if (!key) return null
|
||||
const val = menuBadges[key]?.value || 0
|
||||
if (!val) return null
|
||||
return key === 'agendaHoje' ? `${val} hoje` : String(val)
|
||||
}
|
||||
|
||||
const sections = computed(() => {
|
||||
const model = menuStore.model || []
|
||||
return model
|
||||
@@ -389,6 +399,7 @@ watch(() => route.path, () => hideMobileMenu())
|
||||
<i v-if="child.icon" :class="child.icon" class="rs__item-icon" />
|
||||
<span>{{ child.label }}</span>
|
||||
<span v-if="isLocked(child)" class="rs__pro">PRO</span>
|
||||
<span v-if="menuBadgeLabel(child)" class="rs__badge">{{ menuBadgeLabel(child) }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
@@ -405,6 +416,7 @@ watch(() => route.path, () => hideMobileMenu())
|
||||
<i v-if="item.icon" :class="item.icon" class="rs__item-icon" />
|
||||
<span>{{ item.label }}</span>
|
||||
<span v-if="isLocked(item)" class="rs__pro">PRO</span>
|
||||
<span v-if="menuBadgeLabel(item)" class="rs__badge">{{ menuBadgeLabel(item) }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
@@ -672,6 +684,15 @@ watch(() => route.path, () => hideMobileMenu())
|
||||
border: 1px solid var(--surface-border);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
.rs__badge {
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--primary-color);
|
||||
color: #fff;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ── Slide-in da esquerda ────────────────────────────────── */
|
||||
.rs-slide-enter-active,
|
||||
|
||||
@@ -11,7 +11,7 @@ let outsideClickListener = null
|
||||
|
||||
// ✅ rota mudou:
|
||||
// - atualiza activePath sempre (desktop e mobile)
|
||||
// - fecha menu SOMENTE no mobile (evita “sumir” no desktop / inconsistências)
|
||||
// - fecha menu SOMENTE no mobile (evita "sumir" no desktop / inconsistências)
|
||||
watch(
|
||||
() => route.path,
|
||||
(newPath) => {
|
||||
|
||||
@@ -495,7 +495,7 @@ async function logout () {
|
||||
}
|
||||
|
||||
/**
|
||||
* ✅ Bootstrap entitlements (resolve “menu não alterna” sem depender do guard)
|
||||
* ✅ Bootstrap entitlements (resolve "menu não alterna" sem depender do guard)
|
||||
* - se tem tenant ativo => carrega tenant entitlements
|
||||
* - senão => carrega user entitlements
|
||||
*/
|
||||
|
||||
@@ -44,7 +44,7 @@ const layoutState = reactive({
|
||||
*
|
||||
* Motivo: você aplica tema cedo (main.js / user_settings) e depois
|
||||
* usa o composable em páginas/Topbar/Configurator. Se não sincronizar,
|
||||
* isDarkTheme pode ficar “mentindo”.
|
||||
* isDarkTheme pode ficar "mentindo".
|
||||
*/
|
||||
let _syncedDarkFromDomOnce = false
|
||||
function syncDarkFromDomOnce () {
|
||||
|
||||
Reference in New Issue
Block a user