This commit is contained in:
Leonardo
2026-03-06 06:37:13 -03:00
parent d58dc21297
commit f733db8436
146 changed files with 43436 additions and 12779 deletions
-7
View File
@@ -1,7 +0,0 @@
<template>
<AppShellLayout />
</template>
<script setup>
import AppShellLayout from './AppShellLayout.vue'
</script>
+4 -7
View File
@@ -1,15 +1,12 @@
<script setup>
import { computed, inject } from 'vue'
import { useLayout } from '@/layout/composables/layout'
import SelectButton from 'primevue/selectbutton'
import { primaryColors, surfaces, presetOptions, applyThemeEngine } from '@/theme/theme.options'
const { layoutConfig, isDarkTheme, changeMenuMode } = useLayout()
// ✅ vem do AppTopbar (mesma instância)
const queuePatch = inject('queueUserSettingsPatch', null)
console.log('[AppConfigurator] queuePatch injected?', !!queuePatch)
// menu mode options
const menuModeOptions = [
@@ -35,14 +32,14 @@ const menuModeModel = computed({
if (!val || val === layoutConfig.menuMode) return
layoutConfig.menuMode = val
// composable pode aceitar nada (no teu caso, costuma ser isso)
try { changeMenuMode() } catch {}
// ✅ changeMenuMode espera event.value (seu composable usa event.value)
try { changeMenuMode({ value: val }) } catch {}
queuePatch?.({ menu_mode: val })
}
})
function updateColors(type, item) {
function updateColors (type, item) {
if (type === 'primary') {
layoutConfig.primary = item.name
applyThemeEngine(layoutConfig)
@@ -116,4 +113,4 @@ function updateColors(type, item) {
</div>
</div>
</div>
</template>
</template>
+150 -23
View File
@@ -1,34 +1,161 @@
<script setup>
import { useLayout } from '@/layout/composables/layout';
import { computed } from 'vue';
import AppFooter from './AppFooter.vue';
import AppSidebar from './AppSidebar.vue';
import AppTopbar from './AppTopbar.vue';
import { useLayout } from '@/layout/composables/layout'
import { computed, onMounted, onBeforeUnmount, provide } from 'vue'
import { useRoute } from 'vue-router'
const { layoutConfig, layoutState, hideMobileMenu } = useLayout();
import AppFooter from './AppFooter.vue'
import AppSidebar from './AppSidebar.vue'
import AppTopbar from './AppTopbar.vue'
import AppRail from './AppRail.vue'
import AppRailPanel from './AppRailPanel.vue'
import AppRailTopbar from './AppRailTopbar.vue'
import { useTenantStore } from '@/stores/tenantStore'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
const route = useRoute()
const { layoutConfig, layoutState, hideMobileMenu, isDesktop } = useLayout()
// ✅ área do layout definida por rota (shell único)
const layoutArea = computed(() => route.meta?.area || null)
provide('layoutArea', layoutArea)
const tenantStore = useTenantStore()
const entitlementsStore = useEntitlementsStore()
const tf = useTenantFeaturesStore()
const containerClass = computed(() => {
return {
'layout-overlay': layoutConfig.menuMode === 'overlay',
'layout-static': layoutConfig.menuMode === 'static',
'layout-overlay-active': layoutState.overlayMenuActive,
'layout-mobile-active': layoutState.mobileMenuActive,
'layout-static-inactive': layoutState.staticMenuInactive
};
});
return {
'layout-overlay': layoutConfig.menuMode === 'overlay',
'layout-static': layoutConfig.menuMode === 'static',
'layout-overlay-active': layoutState.overlayMenuActive,
'layout-mobile-active': layoutState.mobileMenuActive,
'layout-static-inactive': layoutState.staticMenuInactive
}
})
function getTenantId () {
return (
tenantStore.activeTenantId ||
tenantStore.tenantId ||
tenantStore.currentTenantId ||
tenantStore.tenant?.id ||
null
)
}
async function revalidateAfterSessionRefresh () {
try {
if (!tenantStore.loaded && !tenantStore.loading) {
await tenantStore.loadSessionAndTenant()
}
const tid = getTenantId()
if (!tid) return
await Promise.allSettled([
entitlementsStore.loadForTenant?.(tid, { force: true }),
tf.fetchForTenant?.(tid, { force: true })
])
} catch (e) {
console.warn('[AppLayout] revalidateAfterSessionRefresh failed:', e?.message || e)
}
}
function onSessionRefreshed () {
// ✅ Só revalidar tenantStore/entitlements em áreas TENANT.
// Em /portal e /account isso causa vazamento de contexto e troca de menu.
const p = String(route.path || '')
const isTenantArea =
p.startsWith('/admin') ||
p.startsWith('/therapist') ||
p.startsWith('/supervisor') ||
p.startsWith('/saas')
if (!isTenantArea) return
revalidateAfterSessionRefresh()
}
onMounted(() => {
window.addEventListener('app:session-refreshed', onSessionRefreshed)
})
onBeforeUnmount(() => {
window.removeEventListener('app:session-refreshed', onSessionRefreshed)
})
</script>
<template>
<div class="layout-wrapper" :class="containerClass">
<AppTopbar />
<AppSidebar />
<div class="layout-main-container">
<div class="layout-main">
<router-view />
</div>
<AppFooter />
<!-- Layout 2: Rail + Painel + Main (full-width) -->
<template v-if="layoutConfig.variant === 'rail' && isDesktop()">
<div class="l2-root">
<AppRail />
<div class="l2-body">
<AppRailTopbar />
<div class="l2-content">
<AppRailPanel />
<div class="l2-main">
<router-view />
</div>
</div>
<div class="layout-mask animate-fadein" @click="hideMobileMenu" />
</div>
</div>
<Toast />
</template>
<!-- Layout 1: Clássico -->
<template v-else>
<div class="layout-wrapper" :class="containerClass">
<AppTopbar />
<AppSidebar />
<div class="layout-main-container">
<div class="layout-main">
<router-view />
</div>
<AppFooter />
</div>
<div class="layout-mask animate-fadein" @click="hideMobileMenu" />
</div>
<Toast />
</template>
</template>
<style scoped>
/* ─── Layout 2 ───────────────────────────────────────────── */
.l2-root {
display: flex;
width: 100vw;
height: 100vh;
overflow: hidden;
background: var(--surface-ground);
}
/* Coluna direita do rail: topbar + conteúdo */
.l2-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
/* Linha: painel lateral + main */
.l2-content {
flex: 1;
min-height: 0;
display: flex;
overflow: hidden;
}
/* Área de conteúdo principal */
.l2-main {
flex: 1;
min-width: 0;
overflow-y: auto;
overflow-x: hidden;
/* Headers sticky no Rail colam no topo do scroll container (já abaixo da topbar) */
--layout-sticky-top: 0px;
}
</style>
+82 -131
View File
@@ -7,114 +7,84 @@ import AppMenuItem from './AppMenuItem.vue'
import AppMenuFooterPanel from './AppMenuFooterPanel.vue'
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue'
import FloatLabel from 'primevue/floatlabel'
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
import InputText from 'primevue/inputtext'
import { sessionRole, sessionIsSaasAdmin } from '@/app/session'
import { getMenuByRole } from '@/navigation'
import { useTenantStore } from '@/stores/tenantStore'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
import { useMenuStore } from '@/stores/menuStore'
const route = useRoute()
const router = useRouter()
const { layoutState } = useLayout()
const tenantStore = useTenantStore()
const entitlementsStore = useEntitlementsStore()
const menuStore = useMenuStore()
const tenantId = computed(() => tenantStore.activeTenantId || null)
// ======================================================
// ✅ 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)
// ======================================================
/**
* ✅ Role canônico pro MENU:
* - PRIORIDADE 1: contexto de rota (evita menu errado quando role do tenant atrasa/falha)
* Ex.: /therapist/* => força menu therapist; /admin* => força menu admin
* - PRIORIDADE 2: se há tenant ativo: usa role do tenant
* - PRIORIDADE 3: fallback pro sessionRole (ex.: telas fora de tenant)
*
* Motivo: o bug que você descreveu (terapeuta vendo admin.menu) geralmente é:
* - tenant role ainda não carregou OU tenantId está null
* - sessionRole vem como 'admin'
* Então, rota > tenant > session elimina o menu “trocar sozinho”.
*/
const navRole = computed(() => {
const p = String(route.path || '')
// raw (pode piscar vazio)
const rawModel = computed(() => menuStore.model || [])
// ✅ blindagem por contexto
if (p.startsWith('/therapist')) return 'therapist'
if (p.startsWith('/admin') || p.startsWith('/clinic')) return 'clinic_admin'
if (p.startsWith('/patient')) return 'patient'
// último menu válido
const lastGoodModel = ref([])
// ✅ dentro de tenant: confia no role do tenant
if (tenantId.value) return tenantStore.activeRole || null
// debounce curto para aceitar "vazio real" (ex.: logout) sem travar UI
let acceptEmptyT = null
// ✅ fora de tenant: fallback pro sessionRole
return sessionRole.value || null
})
const model = computed(() => {
// ✅ role efetivo do menu já vem “canônico” do navRole
const effectiveRole = navRole.value
const base = getMenuByRole(effectiveRole, { isSaasAdmin: sessionIsSaasAdmin.value }) || []
const normalize = (s) => String(s || '').toLowerCase()
const priorityOrder = (group) => {
const label = normalize(group?.label)
if (label.includes('saas')) return 0
if (label.includes('pacientes')) return 1
return 99
function setLastGoodIfValid (m) {
if (Array.isArray(m) && m.length) {
lastGoodModel.value = m
}
}
return [...base].sort((a, b) => priorityOrder(a) - priorityOrder(b))
})
// quando troca tenant -> recarrega entitlements
watch(
tenantId,
async (id) => {
entitlementsStore.invalidate()
if (id) await entitlementsStore.loadForTenant(id, { force: true })
rawModel,
(m) => {
// se veio com itens, atualiza na hora
if (Array.isArray(m) && m.length) {
if (acceptEmptyT) clearTimeout(acceptEmptyT)
setLastGoodIfValid(m)
return
}
// se veio vazio, NÃO derruba o menu imediatamente.
// Só aceita vazio se continuar vazio por um tempinho.
if (acceptEmptyT) clearTimeout(acceptEmptyT)
acceptEmptyT = setTimeout(() => {
// se ainda estiver vazio, e você quiser realmente limpar, faça aqui.
// Por padrão, manteremos o último menu válido para evitar UX quebrada.
// Se quiser limpar em logout, o logout deve limpar lastGoodModel explicitamente.
}, 250)
},
{ immediate: true }
{ immediate: true, deep: false }
)
// ✅ quando troca role efetivo do menu (via rota/tenant/session) -> recarrega entitlements do tenant atual
watch(
() => navRole.value,
async () => {
if (!tenantId.value) return
entitlementsStore.invalidate()
await entitlementsStore.loadForTenant(tenantId.value, { force: true })
}
)
// model final exibido (com fallback)
const model = computed(() => {
const m = rawModel.value
if (Array.isArray(m) && m.length) return m
if (Array.isArray(lastGoodModel.value) && lastGoodModel.value.length) return lastGoodModel.value
return []
})
// ✅ rota -> activePath (NÃO fecha menu em nenhum cenário)
// ✅ rota -> activePath (NÃO fecha menu)
watch(
() => route.path,
(p) => { layoutState.activePath = p },
{ immediate: true }
)
// ==============================
// 🔎 Busca no menu (flatten + resultados)
// ==============================
// ======================================================
// 🔎 Busca no menu (mantive igual)
// ======================================================
const query = ref('')
const showResults = ref(false)
const activeIndex = ref(-1)
// ✅ garante Ctrl/Cmd+K mesmo sem recentes
const forcedOpen = ref(false)
// ref do InputText (pra Ctrl/Cmd + K)
const searchEl = ref(null)
// wrapper pra click-outside
const searchWrapEl = ref(null)
// Recentes
const RECENT_KEY = 'menu_search_recent'
const recent = ref([])
@@ -136,15 +106,11 @@ loadRecent()
watch(query, (v) => {
const hasText = !!v?.trim()
// digitou: abre e sai do modo "forced"
if (hasText) {
forcedOpen.value = false
showResults.value = true
return
}
// vazio: mantém aberto apenas se foi "forçado" (Ctrl/Cmd+K ou foco)
showResults.value = forcedOpen.value
})
@@ -163,10 +129,17 @@ function norm (s) {
.trim()
}
function isVisibleItem (it) {
const v = it?.visible
if (typeof v === 'function') return !!v()
if (v === undefined || v === null) return true
return v !== false
}
function flattenMenu (items, trail = []) {
const out = []
for (const it of (items || [])) {
if (it?.visible === false) continue
if (!isVisibleItem(it)) continue
const nextTrail = [...trail, it?.label].filter(Boolean)
@@ -176,7 +149,10 @@ function flattenMenu (items, trail = []) {
to: it.to,
icon: it.icon,
trail: nextTrail,
proBadge: !!it.proBadge,
// ✅ usa cálculo dinâmico vindo do navigation (quando existir)
proBadge: !!(it.__showProBadge ?? it.proBadge),
feature: it.feature || null
})
}
@@ -210,7 +186,6 @@ watch(results, (list) => {
activeIndex.value = list.length ? 0 : -1
})
// ===== highlight =====
function escapeHtml (s) {
return String(s || '')
.replace(/&/g, '&amp;')
@@ -235,7 +210,6 @@ function highlight (text, q) {
return `${before}<mark class="px-1 rounded bg-yellow-200/40">${mid}</mark>${after}`
}
// ===== teclado =====
function onSearchKeydown (e) {
if (e.key === 'Escape') {
showResults.value = false
@@ -273,7 +247,6 @@ function isTypingTarget (el) {
return tag === 'input' || tag === 'textarea' || el.isContentEditable
}
// ===== Ctrl/Cmd + K =====
function focusSearch () {
forcedOpen.value = true
showResults.value = true
@@ -304,25 +277,18 @@ function onGlobalKeydown (e) {
}
}
// ✅ Recentes: aplicar query + abrir + focar (sem depender de watch timing)
function applyRecent (q) {
query.value = q
forcedOpen.value = true
showResults.value = true
activeIndex.value = 0
nextTick(() => {
// garante foco e teclado funcionando
focusSearch()
})
nextTick(() => focusSearch())
}
// click outside para fechar painel
function onDocMouseDown (e) {
if (!showResults.value) return
const root = searchWrapEl.value
if (!root) return
if (!root.contains(e.target)) {
showResults.value = false
forcedOpen.value = false
@@ -334,6 +300,7 @@ onMounted(() => {
document.addEventListener('mousedown', onDocMouseDown)
})
onBeforeUnmount(() => {
if (acceptEmptyT) clearTimeout(acceptEmptyT)
window.removeEventListener('keydown', onGlobalKeydown, true)
document.removeEventListener('mousedown', onDocMouseDown)
})
@@ -356,7 +323,6 @@ const quickDialog = ref(false)
function onQuickCreate () { quickDialog.value = true }
function onQuickCreated () { quickDialog.value = false }
// controle de “recentes”: mostrar ao focar (mesmo sem recentes, para exibir dicas)
function onSearchFocus () {
if (!query.value?.trim()) {
forcedOpen.value = true
@@ -370,12 +336,29 @@ function onSearchFocus () {
<!-- 🔎 TOPO FIXO -->
<div ref="searchWrapEl" class="pb-2 border-b border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="relative">
<div
aria-hidden="true"
style="position:absolute; left:-9999px; top:-9999px; width:1px; height:1px; overflow:hidden;"
>
<input type="email" name="email" autocomplete="username" tabindex="-1" disabled />
<input type="password" name="password" autocomplete="current-password" tabindex="-1" disabled />
</div>
<FloatLabel variant="on" class="w-full">
<IconField class="w-full">
<InputIcon class="pi pi-search" />
<InputText
ref="searchEl"
id="menu_search"
name="menu_search"
type="search"
inputmode="search"
autocomplete="off"
autocapitalize="off"
autocorrect="off"
spellcheck="false"
data-lpignore="true"
data-1p-ignore="true"
v-model="query"
class="w-full pr-10"
variant="filled"
@@ -383,10 +366,9 @@ function onSearchFocus () {
@keydown="onSearchKeydown"
/>
</IconField>
<label for="menu_search">Buscar no menu</label>
<label for="menu_search">Encontrar menu...</label>
</FloatLabel>
<!-- botão limpar busca -->
<button
v-if="query.trim()"
type="button"
@@ -398,7 +380,6 @@ function onSearchFocus () {
</button>
</div>
<!-- Recentes (quando query vazio) -->
<div
v-if="showResults && !query.trim() && recent.length"
class="mt-2 border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] shadow-sm overflow-hidden"
@@ -427,14 +408,13 @@ function onSearchFocus () {
</button>
</div>
<!-- Resultados -->
<div
v-else-if="showResults && results.length"
class="mt-2 border border-[var(--surface-border)] rounded-xl bg-[var(--surface-card)] shadow-sm overflow-hidden"
>
<button
v-for="(r, i) in results"
:key="r.to"
:key="String(r.to)"
type="button"
@mousedown.prevent="goTo(r)"
:class="[
@@ -449,7 +429,7 @@ function onSearchFocus () {
</div>
<span
v-if="r.proBadge || r.feature"
v-if="r.proBadge"
class="text-xs px-2 py-0.5 rounded border border-[var(--surface-border)] opacity-80"
>
PRO
@@ -457,48 +437,19 @@ function onSearchFocus () {
</button>
</div>
<div
v-else-if="showResults && query && !results.length"
class="mt-2 px-3 py-2 text-sm opacity-70"
>
<div v-else-if="showResults && query && !results.length" class="mt-2 px-3 py-2 text-sm opacity-70">
Nenhum item encontrado.
</div>
<!-- instruções embaixo quando houver recentes/resultados/uso -->
<div
v-if="showResults && (recent.length || results.length || query.trim())"
class="mt-2 px-3 text-xs opacity-70 flex flex-wrap gap-x-3 gap-y-1"
>
<span><b>Ctrl+K</b>/<b>Cmd+K</b> focar</span>
<span><b></b> navegar</span>
<span><b>Enter</b> abrir</span>
<span><b>Esc</b> fechar</span>
</div>
<!-- fallback quando não tem nada -->
<div
v-else-if="showResults && !query.trim() && !recent.length"
class="mt-2 px-3 py-2 text-xs opacity-60"
>
Dica: pressione <b>Ctrl + K</b> (ou <b>Cmd + K</b>) para buscar.
</div>
</div>
<!-- SOMENTE O MENU ROLA -->
<div class="flex-1 overflow-y-auto">
<ul class="layout-menu pb-20">
<template v-for="(item, i) in model" :key="i">
<AppMenuItem
:item="item"
:index="i"
:root="true"
@quick-create="onQuickCreate"
/>
<AppMenuItem :item="item" :index="i" :root="true" @quick-create="onQuickCreate" />
</template>
</ul>
</div>
<!-- rodapé fixo -->
<AppMenuFooterPanel />
<ComponentCadastroRapido
+36 -51
View File
@@ -2,6 +2,8 @@
import { computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import Popover from 'primevue/popover'
import { sessionUser, sessionRole } from '@/app/session'
import { supabase } from '@/lib/supabase/client'
@@ -13,7 +15,7 @@ const pop = ref(null)
// ------------------------------------------------------
// RBAC (Tenant): fonte da verdade para permissões por papel
// ------------------------------------------------------
const { role, canSee, isPatient } = useRoleGuard()
const { role, canSee } = useRoleGuard()
// ------------------------------------------------------
// UI labels (nome/iniciais)
@@ -33,22 +35,21 @@ const label = computed(() => {
/**
* sublabel:
* Aqui eu recomendo exibir o papel do TENANT (role do useRoleGuard),
* porque é ele que realmente governa a UI dentro da clínica.
*
* Se você preferir manter sessionRole como rótulo "global", ok,
* mas isso pode confundir quando o usuário estiver em contextos diferentes.
* 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 (confirmados no banco): tenant_admin | therapist | patient
if (r === 'tenant_admin') return 'Administrador'
// tenant roles
if (r === 'clinic_admin' || r === 'tenant_admin' || r === 'admin') return 'Administrador'
if (r === 'therapist') return 'Terapeuta'
if (r === 'patient') return 'Paciente'
// fallback (caso venha algo diferente)
// portal/global roles
if (r === 'portal_user') return 'Portal'
if (r === 'patient') return 'Portal' // legado (caso ainda exista em algum lugar)
return r
})
@@ -60,69 +61,52 @@ function toggle (e) {
}
function close () {
try {
pop.value?.hide()
} catch {}
try { pop.value?.hide() } catch {}
}
// ------------------------------------------------------
// Navegação segura (NAME com fallback)
// Navegação segura (resolve antes; fallback se não existir)
// ------------------------------------------------------
async function safePush (target, fallback) {
try {
await router.push(target)
} catch (e) {
// fallback quando o "name" não existe no router
if (fallback) {
try {
await router.push(fallback)
} catch {
await router.push('/')
}
} else {
await router.push('/')
}
const r = router.resolve(target)
if (r?.matched?.length) return await router.push(target)
} catch {}
if (fallback) {
try { return await router.push(fallback) } catch {}
}
return router.push('/')
}
// ------------------------------------------------------
// Actions
// ------------------------------------------------------
function goMyProfile () {
close()
// Navegação segura para Account → Profile
safePush(
{ name: 'account-profile' },
'/account/profile'
)
safePush({ name: 'account-profile' }, '/account/profile')
}
function goSettings () {
close()
// ✅ Decide por RBAC (tenant role), não por sessionRole
// ✅ Configurações é RBAC (quem pode ver, vê)
if (canSee('settings.view')) {
router.push({ name: 'ConfiguracoesAgenda' })
return
return safePush({ name: 'ConfiguracoesAgenda' }, '/admin/settings') // fallback genérico
}
// Se não pode ver configurações, manda paciente pro portal.
// (Se amanhã você criar outro papel, esta regra continua segura.)
if (isPatient.value) {
router.push('/patient/portal')
return
}
router.push('/')
// ✅ quem não pode (ex.: paciente), manda pro portal correto
return safePush({ name: 'portal-sessoes' }, '/portal')
}
function goSecurity () {
close()
// ✅ 1) tenta por NAME (recomendado)
// ✅ 2) fallback: caminhos mais prováveis do teu projeto
// Ajuste/defina a rota no router como name: 'AdminSecurity' para ficar perfeito
safePush(
{ name: 'AdminSecurity' },
'/admin/settings/security'
// ✅ Segurança é "Account": todos podem acessar
return safePush(
{ name: 'account-security' },
'/account/security'
)
}
@@ -147,9 +131,10 @@ async function signOut () {
>
<!-- avatar -->
<img
v-if="sessionUser.value?.user_metadata?.avatar_url"
:src="sessionUser.value.user_metadata.avatar_url"
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
+68 -39
View File
@@ -1,10 +1,10 @@
<!-- src/layout/AppMenuItem.vue -->
<script setup>
import { useLayout } from '@/layout/composables/layout'
import { computed, ref, nextTick } from 'vue'
import { computed, ref, nextTick, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import Popover from 'primevue/popover'
import Button from 'primevue/button'
import { useTenantStore } from '@/stores/tenantStore'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
@@ -31,54 +31,85 @@ const fullPath = computed(() =>
)
// ==============================
// Active logic: mantém submenu aberto se algum descendente estiver ativo
// Visible: boolean OU function() -> boolean
// ==============================
const isVisible = computed(() => {
const v = props.item?.visible
if (typeof v === 'function') return !!v()
if (v === undefined || v === null) return true
return v !== false
})
// ==============================
// Helpers de rota: aceita string OU objeto
// ==============================
function toPath (to) {
if (!to) return ''
if (typeof to === 'string') return to
try { return router.resolve(to).path || '' } catch { return '' }
}
// ==============================
// Active logic
// ==============================
function isSameRoute (current, target) {
if (!current || !target) return false
return current === target || current.startsWith(target + '/')
const cur = typeof current === 'string' ? current : toPath(current)
const tar = typeof target === 'string' ? target : toPath(target)
if (!cur || !tar) return false
return cur === tar || cur.startsWith(tar + '/')
}
function hasActiveDescendant (node, currentPath) {
const children = node?.items || []
for (const child of children) {
if (child?.to && isSameRoute(currentPath, child.to)) return true
const childTo = toPath(child?.to)
if (childTo && isSameRoute(currentPath, childTo)) return true
if (child?.items?.length && hasActiveDescendant(child, currentPath)) return true
}
return false
}
const isActive = computed(() => {
const current = layoutState.activePath || ''
const current = typeof layoutState.activePath === 'string'
? layoutState.activePath
: toPath(layoutState.activePath)
const item = props.item
// grupo com submenu: active se qualquer descendente estiver ativo
if (item?.items?.length) {
if (hasActiveDescendant(item, current)) return true
// fallback pelo "path" (útil para rotas internas tipo /saas/plans/123)
return item.path ? current.startsWith(fullPath.value || '') : false
}
// folha: active se rota igual ao to
return item?.to ? isSameRoute(current, item.to) : false
const leafTo = toPath(item?.to)
return leafTo ? isSameRoute(current, leafTo) : false
})
// ==============================
// Feature lock + label
// ✅ PRO badge (agora 100% por entitlementsStore)
// ==============================
const ownerId = computed(() => tenantStore.activeTenantId || null)
const isLocked = computed(() => {
const showProBadge = computed(() => {
const feature = props.item?.feature
return !!(props.item?.proBadge && feature && ownerId.value && !entitlementsStore.has(feature))
if (!props.item?.proBadge || !feature) return false
// se tiver a feature, NÃO é PRO (não mostra badge)
// se NÃO tiver, mostra PRO
try {
return !entitlementsStore.has(feature)
} catch {
// se der erro, não mostra (evita “PRO fantasma”)
return false
}
})
// 🔒 locked quando precisa mostrar PRO (ou seja, não tem feature)
const isLocked = computed(() => !!(props.item?.proBadge && showProBadge.value))
const itemDisabled = computed(() => !!props.item?.disabled)
const isBlocked = computed(() => itemDisabled.value || isLocked.value)
const labelText = computed(() => {
const base = props.item?.label || ''
return props.item?.proBadge && isLocked.value ? `${base} (PRO)` : base
return props.item?.label || ''
})
const itemClick = async (event, item) => {
@@ -96,17 +127,14 @@ const itemClick = async (event, item) => {
return
}
// 🚫 disabled -> bloqueia
if (itemDisabled.value) {
event.preventDefault()
event.stopPropagation()
return
}
// commands
if (item?.command) item.command({ originalEvent: event, item })
// ✅ submenu: expande/colapsa e não navega
if (item?.items?.length) {
event.preventDefault()
event.stopPropagation()
@@ -114,24 +142,22 @@ const itemClick = async (event, item) => {
if (isActive.value) {
layoutState.activePath = props.parentPath || ''
} else {
layoutState.activePath = fullPath.value
layoutState.activePath = fullPath.value || ''
layoutState.menuHoverActive = true
}
return
}
// ✅ leaf: marca ativo e NÃO fecha menu
if (item?.to) layoutState.activePath = item.to
if (item?.to) layoutState.activePath = toPath(item.to)
}
const onMouseEnter = () => {
if (isDesktop() && props.root && props.item?.items && layoutState.menuHoverActive) {
layoutState.activePath = fullPath.value
layoutState.activePath = fullPath.value || ''
}
}
/* ---------- POPUP + ---------- */
function togglePopover (event) {
if (isBlocked.value) return
pop.value?.toggle(event)
@@ -143,10 +169,7 @@ function closePopover () {
function abrirCadastroRapido () {
closePopover()
emit('quick-create', {
entity: props.item?.quickCreateEntity || 'patient',
mode: 'rapido'
})
emit('quick-create', { entity: props.item?.quickCreateEntity || 'patient', mode: 'rapido' })
}
async function irCadastroCompleto () {
@@ -157,17 +180,17 @@ async function irCadastroCompleto () {
layoutState.menuHoverActive = false
await nextTick()
router.push('/admin/patients/cadastro')
router.push({ name: 'admin-pacientes-cadastro' })
}
</script>
<template>
<li :class="{ 'layout-root-menuitem': root, 'active-menuitem': isActive }">
<div v-if="root && item.visible !== false" class="layout-menuitem-root-text">
<li v-show="isVisible" :class="{ 'layout-root-menuitem': root, 'active-menuitem': isActive }">
<div v-if="root" class="layout-menuitem-root-text">
{{ item.label }}
</div>
<div v-if="!root && item.visible !== false" class="flex align-items-center justify-content-between w-full">
<div v-if="!root" class="flex align-items-center justify-content-between w-full">
<component
:is="item.to && !item.items ? 'router-link' : 'a'"
v-bind="item.to && !item.items ? { to: item.to } : { href: item.url }"
@@ -183,8 +206,14 @@ async function irCadastroCompleto () {
<span class="layout-menuitem-text">
{{ labelText }}
<!-- (debug) pode remover depois -->
<small style="opacity:.6">[locked={{ isLocked }}]</small>
</span>
<!-- Badge PRO some quando tem entitlements -->
<span
v-if="item.proBadge && showProBadge"
class="ml-2 text-xs px-2 py-0.5 rounded border border-[var(--surface-border)] opacity-80"
>
PRO
</span>
<i v-if="item.items" class="pi pi-fw pi-angle-down layout-submenu-toggler" />
@@ -209,7 +238,7 @@ async function irCadastroCompleto () {
</div>
</Popover>
<Transition v-if="item.items && item.visible !== false" name="layout-submenu">
<Transition v-if="item.items" name="layout-submenu">
<ul v-show="root ? true : isActive" class="layout-submenu">
<app-menu-item
v-for="child in item.items"
@@ -222,4 +251,4 @@ async function irCadastroCompleto () {
</ul>
</Transition>
</li>
</template>
</template>
+329
View File
@@ -0,0 +1,329 @@
<!-- 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 { supabase } from '@/lib/supabase/client'
const menuStore = useMenuStore()
const { layoutConfig, layoutState, isDesktop } = useLayout()
const router = useRouter()
// ── Seções do rail (derivadas do model) ─────────────────────
const railSections = computed(() => {
const model = menuStore.model || []
return model
.filter(s => s.label && Array.isArray(s.items) && s.items.length)
.map(s => ({
key: s.label,
label: s.label,
icon: s.icon || s.items.find(i => i.icon)?.icon || 'pi pi-fw pi-circle',
items: s.items
}))
})
// ── Avatar / iniciais ────────────────────────────────────────
const avatarUrl = computed(() => sessionUser.value?.user_metadata?.avatar_url || null)
const initials = computed(() => {
const name = sessionUser.value?.user_metadata?.full_name || sessionUser.value?.email || ''
const parts = String(name).trim().split(/\s+/).filter(Boolean)
const a = parts[0]?.[0] || 'U'
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')
// ── Seleção de seção ─────────────────────────────────────────
function selectSection (section) {
if (layoutState.railSectionKey === section.key && layoutState.railPanelOpen) {
layoutState.railPanelOpen = false
} else {
layoutState.railSectionKey = section.key
layoutState.railPanelOpen = true
}
}
function isActiveSectionOrChild (section) {
if (layoutState.railSectionKey === section.key && layoutState.railPanelOpen) return true
// verifica se algum filho está ativo
const active = String(layoutState.activePath || '')
return section.items.some(i => {
const p = typeof i.to === 'string' ? i.to : ''
return p && active.startsWith(p)
})
}
// ── 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')
}
</script>
<template>
<aside class="rail">
<!-- Brand -->
<div class="rail__brand">
<span class="rail__psi">Ψ</span>
</div>
<!-- Nav icons -->
<nav class="rail__nav" role="navigation" aria-label="Menu principal">
<button
v-for="section in railSections"
:key="section.key"
class="rail__btn"
:class="{ 'rail__btn--active': isActiveSectionOrChild(section) }"
v-tooltip.right="{ value: section.label, showDelay: 400 }"
:aria-label="section.label"
@click="selectSection(section)"
>
<i :class="section.icon" />
</button>
</nav>
<!-- Rodapé -->
<div class="rail__foot">
<!-- Configurações de layout -->
<button
class="rail__btn rail__btn--sm"
v-tooltip.right="{ value: 'Meu Perfil', showDelay: 400 }"
aria-label="Meu Perfil"
@click="goTo('/account/profile')"
>
<i class="pi pi-fw pi-cog" />
</button>
<!-- Avatar / user -->
<button
class="rail__av-btn"
v-tooltip.right="{ value: userName, showDelay: 400 }"
:aria-label="userName"
@click="toggleUserPop"
>
<img v-if="avatarUrl" :src="avatarUrl" class="rail__av-img" :alt="userName" />
<span v-else class="rail__av-init">{{ initials }}</span>
</button>
</div>
<!-- Popover usuário -->
<Popover ref="userPop" appendTo="body">
<div class="rail-pop">
<div class="rail-pop__user">
<div class="rail-pop__av">
<img v-if="avatarUrl" :src="avatarUrl" class="rail-pop__av-img" />
<span v-else class="rail-pop__av-init">{{ initials }}</span>
</div>
<div class="min-w-0">
<div class="rail-pop__name">{{ userName }}</div>
<div class="rail-pop__email">{{ sessionUser?.email }}</div>
</div>
</div>
<div class="rail-pop__divider" />
<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="rail-pop__divider" />
<Button label="Sair" icon="pi pi-sign-out" severity="danger" text class="w-full justify-start" @click="signOut" />
</div>
</Popover>
</aside>
</template>
<style scoped>
/* ─── Rail container ─────────────────────────────────────── */
.rail {
width: 60px;
flex-shrink: 0;
height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
border-right: 1px solid var(--surface-border);
background: var(--surface-card);
z-index: 50;
user-select: none;
}
/* ─── Brand ──────────────────────────────────────────────── */
.rail__brand {
width: 100%;
height: 56px;
display: grid;
place-items: center;
border-bottom: 1px solid var(--surface-border);
flex-shrink: 0;
}
.rail__psi {
font-size: 1.35rem;
font-weight: 800;
color: var(--primary-color);
text-shadow: 0 0 20px color-mix(in srgb, var(--primary-color) 40%, transparent);
line-height: 1;
}
/* ─── Nav ────────────────────────────────────────────────── */
.rail__nav {
flex: 1;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 10px 0;
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none;
}
.rail__nav::-webkit-scrollbar { display: none; }
/* ─── Buttons ────────────────────────────────────────────── */
.rail__btn {
width: 40px;
height: 40px;
border-radius: 10px;
display: grid;
place-items: center;
border: none;
background: transparent;
color: var(--text-color-secondary);
cursor: pointer;
font-size: 1rem;
transition: background 0.15s, color 0.15s, transform 0.12s;
position: relative;
flex-shrink: 0;
}
.rail__btn:hover {
background: var(--surface-ground);
color: var(--text-color);
transform: scale(1.08);
}
.rail__btn--active {
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
color: var(--primary-color);
}
.rail__btn--active::before {
content: '';
position: absolute;
left: -10px;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 22px;
border-radius: 0 3px 3px 0;
background: var(--primary-color);
}
.rail__btn--sm {
width: 36px;
height: 36px;
font-size: 0.875rem;
}
/* ─── Footer ─────────────────────────────────────────────── */
.rail__foot {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 8px 0 12px;
border-top: 1px solid var(--surface-border);
}
/* ─── Avatar button ──────────────────────────────────────── */
.rail__av-btn {
width: 36px;
height: 36px;
border-radius: 10px;
border: none;
cursor: pointer;
overflow: hidden;
transition: transform 0.12s, box-shadow 0.15s;
background: var(--surface-ground);
display: grid;
place-items: center;
flex-shrink: 0;
}
.rail__av-btn:hover {
transform: scale(1.08);
box-shadow: 0 0 0 2px var(--primary-color);
}
.rail__av-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.rail__av-init {
font-size: 0.78rem;
font-weight: 700;
color: var(--text-color);
}
/* ─── Popover ────────────────────────────────────────────── */
.rail-pop {
min-width: 210px;
padding: 4px;
display: flex;
flex-direction: column;
gap: 2px;
}
.rail-pop__user {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px 10px;
}
.rail-pop__av {
width: 36px;
height: 36px;
border-radius: 9px;
overflow: hidden;
flex-shrink: 0;
background: var(--surface-ground);
display: grid;
place-items: center;
border: 1px solid var(--surface-border);
}
.rail-pop__av-img { width: 100%; height: 100%; object-fit: cover; }
.rail-pop__av-init { font-size: 0.78rem; font-weight: 700; color: var(--text-color); }
.rail-pop__name {
font-size: 0.83rem;
font-weight: 600;
color: var(--text-color);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rail-pop__email {
font-size: 0.68rem;
color: var(--text-color-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.rail-pop__divider {
height: 1px;
background: var(--surface-border);
margin: 2px 0;
}
</style>
+246
View File
@@ -0,0 +1,246 @@
<!-- src/layout/AppRailPanel.vue Painel expansível do Layout 2 -->
<script setup>
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useMenuStore } from '@/stores/menuStore'
import { useLayout } from './composables/layout'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
const menuStore = useMenuStore()
const { layoutState } = useLayout()
const entitlements = useEntitlementsStore()
const router = useRouter()
const route = useRoute()
// ── Seção ativa ──────────────────────────────────────────────
const currentSection = computed(() => {
const model = menuStore.model || []
return model.find(s => s.label === layoutState.railSectionKey) || null
})
// ── Items da seção (com suporte a children) ──────────────────
const sectionItems = computed(() => currentSection.value?.items || [])
function isLocked (item) {
if (!item.proBadge || !item.feature) return false
try { return !entitlements.has(item.feature) } catch { return false }
}
function isActive (item) {
const active = String(layoutState.activePath || route.path || '')
if (!item.to) return false
const p = typeof item.to === 'string' ? item.to : ''
return active === p || active.startsWith(p + '/')
}
function navigate (item) {
if (isLocked(item)) {
router.push({ name: 'upgrade', query: { feature: item.feature || '' } })
return
}
if (item.to) {
layoutState.activePath = typeof item.to === 'string' ? item.to : router.resolve(item.to).path
router.push(item.to)
}
}
function closePanel () {
layoutState.railPanelOpen = false
}
</script>
<template>
<Transition name="panel-slide">
<aside
v-if="layoutState.railPanelOpen && currentSection"
class="rp"
aria-label="Menu lateral"
>
<!-- Header -->
<div class="rp__head">
<span class="rp__title">{{ currentSection.label }}</span>
<button class="rp__close" aria-label="Fechar painel" @click="closePanel">
<i class="pi pi-times" />
</button>
</div>
<!-- Items -->
<nav class="rp__nav">
<template v-for="item in sectionItems" :key="item.to || item.label">
<!-- Item com filhos (sub-seção) -->
<div v-if="item.items?.length" class="rp__group">
<div class="rp__group-label">{{ item.label }}</div>
<button
v-for="child in item.items"
:key="child.to || child.label"
class="rp__item"
:class="{
'rp__item--active': isActive(child),
'rp__item--locked': isLocked(child)
}"
@click="navigate(child)"
>
<i v-if="child.icon" :class="child.icon" class="rp__item-icon" />
<span class="rp__item-label">{{ child.label }}</span>
<span v-if="isLocked(child)" class="rp__pro">PRO</span>
</button>
</div>
<!-- Item folha -->
<button
v-else
class="rp__item"
:class="{
'rp__item--active': isActive(item),
'rp__item--locked': isLocked(item)
}"
@click="navigate(item)"
>
<i v-if="item.icon" :class="item.icon" class="rp__item-icon" />
<span class="rp__item-label">{{ item.label }}</span>
<span v-if="isLocked(item)" class="rp__pro">PRO</span>
</button>
</template>
</nav>
</aside>
</Transition>
</template>
<style scoped>
/* ─── Panel ──────────────────────────────────────────────── */
.rp {
width: 260px;
flex-shrink: 0;
height: 100vh;
display: flex;
flex-direction: column;
border-right: 1px solid var(--surface-border);
background: var(--surface-card);
overflow: hidden;
}
/* ─── Header ─────────────────────────────────────────────── */
.rp__head {
height: 56px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
border-bottom: 1px solid var(--surface-border);
}
.rp__title {
font-size: 0.9rem;
font-weight: 700;
letter-spacing: -0.01em;
color: var(--text-color);
}
.rp__close {
width: 28px;
height: 28px;
border-radius: 7px;
border: none;
background: transparent;
color: var(--text-color-secondary);
cursor: pointer;
display: grid;
place-items: center;
font-size: 0.75rem;
transition: background 0.15s, color 0.15s;
}
.rp__close:hover {
background: var(--surface-ground);
color: var(--text-color);
}
/* ─── Nav list ───────────────────────────────────────────── */
.rp__nav {
flex: 1;
overflow-y: auto;
padding: 10px 8px;
display: flex;
flex-direction: column;
gap: 2px;
scrollbar-width: thin;
scrollbar-color: var(--surface-border) transparent;
}
/* ─── Group ──────────────────────────────────────────────── */
.rp__group {
display: flex;
flex-direction: column;
gap: 1px;
margin-top: 12px;
}
.rp__group:first-child { margin-top: 0; }
.rp__group-label {
font-size: 0.62rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--text-color-secondary);
opacity: 0.55;
padding: 2px 10px 6px;
}
/* ─── Item ───────────────────────────────────────────────── */
.rp__item {
width: 100%;
display: flex;
align-items: center;
gap: 9px;
padding: 8px 10px;
border-radius: 9px;
border: none;
background: transparent;
color: var(--text-color-secondary);
cursor: pointer;
text-align: left;
font-size: 0.83rem;
font-weight: 500;
transition: background 0.13s, color 0.13s;
}
.rp__item:hover {
background: var(--surface-ground);
color: var(--text-color);
}
.rp__item--active {
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
color: var(--primary-color);
font-weight: 600;
}
.rp__item--locked {
opacity: 0.55;
}
.rp__item-icon {
font-size: 0.85rem;
flex-shrink: 0;
opacity: 0.75;
}
.rp__item-label { flex: 1; }
.rp__pro {
font-size: 0.58rem;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.1em;
padding: 1px 6px;
border-radius: 4px;
border: 1px solid var(--surface-border);
color: var(--text-color-secondary);
opacity: 0.7;
}
/* ─── Slide transition ───────────────────────────────────── */
.panel-slide-enter-active,
.panel-slide-leave-active {
transition: width 0.22s cubic-bezier(0.22, 1, 0.36, 1),
opacity 0.18s ease;
overflow: hidden;
}
.panel-slide-enter-from,
.panel-slide-leave-to {
width: 0 !important;
opacity: 0;
}
</style>
+159
View File
@@ -0,0 +1,159 @@
<!-- src/layout/AppRailTopbar.vue Topbar leve para Layout 2 (Rail) -->
<script setup>
import { computed, ref, nextTick } from 'vue'
import AppConfigurator from './AppConfigurator.vue'
import { useLayout } from '@/layout/composables/layout'
import { useUserSettingsPersistence } from '@/composables/useUserSettingsPersistence'
import { useTenantStore } from '@/stores/tenantStore'
const { toggleDarkMode, isDarkTheme } = useLayout()
const { queuePatch } = useUserSettingsPersistence()
const tenantStore = useTenantStore()
const tenantName = computed(() => {
const t =
tenantStore.activeTenant ||
tenantStore.tenant ||
tenantStore.currentTenant ||
null
return (
t?.name ||
t?.nome ||
t?.display_name ||
t?.fantasy_name ||
t?.razao_social ||
null
)
})
function isDarkNow () {
return document.documentElement.classList.contains('app-dark')
}
async function waitForDarkFlip (before, timeoutMs = 900) {
const start = performance.now()
while (performance.now() - start < timeoutMs) {
await nextTick()
await new Promise((r) => requestAnimationFrame(r))
if (isDarkNow() !== before) return isDarkNow()
}
return isDarkNow()
}
async function toggleDarkAndPersist () {
try {
const before = isDarkNow()
toggleDarkMode()
const after = await waitForDarkFlip(before)
await queuePatch({ theme_mode: after ? 'dark' : 'light' }, { flushNow: true })
} catch (e) {
console.error('[RailTopbar][theme] falhou:', e?.message || e)
}
}
</script>
<template>
<header class="rail-topbar">
<!-- Tenant pill -->
<div class="rail-topbar__left">
<span v-if="tenantName" class="rail-topbar__tenant" :title="tenantName">
{{ tenantName }}
</span>
</div>
<!-- Ações -->
<div class="rail-topbar__actions">
<!-- Dark mode -->
<button
type="button"
class="rail-topbar__btn"
:title="isDarkTheme ? 'Modo claro' : 'Modo escuro'"
@click="toggleDarkAndPersist"
>
<i :class="['pi', isDarkTheme ? 'pi-sun' : 'pi-moon']" />
</button>
<!-- Tema / paleta -->
<div class="relative">
<button
type="button"
class="rail-topbar__btn rail-topbar__btn--highlight"
title="Configurar tema"
v-styleclass="{
selector: '@next',
enterFromClass: 'hidden',
enterActiveClass: 'p-anchored-overlay-enter-active',
leaveToClass: 'hidden',
leaveActiveClass: 'p-anchored-overlay-leave-active',
hideOnOutsideClick: true
}"
>
<i class="pi pi-palette" />
</button>
<AppConfigurator />
</div>
</div>
</header>
</template>
<style scoped>
.rail-topbar {
height: 56px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1.25rem;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-card);
z-index: 20;
}
.rail-topbar__left {
display: flex;
align-items: center;
min-width: 0;
}
.rail-topbar__tenant {
font-size: 0.8rem;
font-weight: 600;
color: var(--text-color-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 320px;
padding: 0.2rem 0.6rem;
border-radius: 999px;
border: 1px solid var(--surface-border);
background: var(--surface-ground);
}
.rail-topbar__actions {
display: flex;
align-items: center;
gap: 0.25rem;
}
.rail-topbar__btn {
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
border: none;
background: transparent;
color: var(--text-color-secondary);
display: grid;
place-items: center;
cursor: pointer;
font-size: 1rem;
transition: background 0.15s, color 0.15s;
}
.rail-topbar__btn:hover {
background: var(--surface-ground);
color: var(--text-color);
}
.rail-topbar__btn--highlight {
color: var(--primary-color);
}
</style>
-25
View File
@@ -1,25 +0,0 @@
<template>
<div class="layout-wrapper">
<AppTopbar @toggleMenu="toggleSidebar" />
<AppSidebar :model="menu" :visible="sidebarVisible" @hide="sidebarVisible=false" />
<div class="layout-main-container">
<div class="layout-main">
<router-view />
</div>
</div>
</div>
</template>
<script setup>
import { computed, ref } from 'vue'
import { useSessionStore } from '@/app/store/sessionStore'
import { getMenuByRole } from '@/navigation'
import AppTopbar from '@/components/layout/AppTopbar.vue'
import AppSidebar from '@/components/layout/AppSidebar.vue'
const sidebarVisible = ref(true)
function toggleSidebar(){ sidebarVisible.value = !sidebarVisible.value }
const session = useSessionStore()
const menu = computed(() => getMenuByRole(session.role))
</script>
+54 -47
View File
@@ -1,66 +1,73 @@
<script setup>
import { useLayout } from '@/layout/composables/layout';
import { onBeforeUnmount, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import AppMenu from './AppMenu.vue';
import { useLayout } from '@/layout/composables/layout'
import { onBeforeUnmount, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import AppMenu from './AppMenu.vue'
const { layoutState, isDesktop, hasOpenOverlay } = useLayout();
const route = useRoute();
const sidebarRef = ref(null);
let outsideClickListener = null;
const { layoutState, isDesktop, hasOpenOverlay, closeMenuOnNavigate } = useLayout()
const route = useRoute()
const sidebarRef = ref(null)
let outsideClickListener = null
// ✅ rota mudou:
// - atualiza activePath sempre (desktop e mobile)
// - fecha menu SOMENTE no mobile (evita “sumir” no desktop / inconsistências)
watch(
() => route.path,
(newPath) => {
if (isDesktop()) layoutState.activePath = null;
else layoutState.activePath = newPath;
layoutState.overlayMenuActive = false;
layoutState.mobileMenuActive = false;
layoutState.menuHoverActive = false;
},
{ immediate: true }
);
() => route.path,
(newPath) => {
layoutState.activePath = newPath
closeMenuOnNavigate?.()
},
{ immediate: true }
)
// mantém o outside click só quando overlay está aberto e estamos em desktop
watch(hasOpenOverlay, (newVal) => {
if (isDesktop()) {
if (newVal) bindOutsideClickListener();
else unbindOutsideClickListener();
}
});
if (isDesktop()) {
if (newVal) bindOutsideClickListener()
else unbindOutsideClickListener()
}
})
const bindOutsideClickListener = () => {
if (!outsideClickListener) {
outsideClickListener = (event) => {
if (isOutsideClicked(event)) {
layoutState.overlayMenuActive = false;
}
};
document.addEventListener('click', outsideClickListener);
if (!outsideClickListener) {
outsideClickListener = (event) => {
if (isOutsideClicked(event)) {
layoutState.overlayMenuActive = false
}
}
};
document.addEventListener('click', outsideClickListener)
}
}
const unbindOutsideClickListener = () => {
if (outsideClickListener) {
document.removeEventListener('click', outsideClickListener);
outsideClickListener = null;
}
};
if (outsideClickListener) {
document.removeEventListener('click', outsideClickListener)
outsideClickListener = null
}
}
const isOutsideClicked = (event) => {
const topbarButtonEl = document.querySelector('.layout-menu-button');
const topbarButtonEl = document.querySelector('.layout-menu-button')
const el = sidebarRef.value
if (!el) return true
return !(sidebarRef.value.isSameNode(event.target) || sidebarRef.value.contains(event.target) || topbarButtonEl?.isSameNode(event.target) || topbarButtonEl?.contains(event.target));
};
return !(
el.isSameNode(event.target) ||
el.contains(event.target) ||
topbarButtonEl?.isSameNode(event.target) ||
topbarButtonEl?.contains(event.target)
)
}
onBeforeUnmount(() => {
unbindOutsideClickListener();
});
unbindOutsideClickListener()
})
</script>
<template>
<div ref="sidebarRef" class="layout-sidebar">
<AppMenu />
</div>
</template>
<div ref="sidebarRef" class="layout-sidebar">
<AppMenu />
</div>
</template>
+465 -85
View File
@@ -1,66 +1,132 @@
<!-- src/layout/AppTopbar.vue -->
<script setup>
import { computed, ref, onMounted, provide, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { useRouter, useRoute } from 'vue-router'
import { useLayout } from '@/layout/composables/layout'
import AppConfigurator from './AppConfigurator.vue'
import Button from 'primevue/button'
import Toast from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import { supabase } from '@/lib/supabase/client'
import { useEntitlementsStore } from '@/stores/entitlementsStore'
import { useTenantStore } from '@/stores/tenantStore'
import { useUserSettingsPersistence } from '@/composables/useUserSettingsPersistence'
import { useRoleGuard } from '@/composables/useRoleGuard'
const { canSee } = useRoleGuard()
// ✅ engine central
import { useUserSettingsPersistence } from '@/composables/useUserSettingsPersistence'
import { applyThemeEngine } from '@/theme/theme.options'
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore'
const toast = useToast()
const entitlementsStore = useEntitlementsStore()
const tenantStore = useTenantStore()
const { toggleMenu, toggleDarkMode, isDarkTheme, layoutConfig, changeMenuMode } = useLayout()
const router = useRouter()
const route = useRoute()
const planBtn = ref(null)
/* ----------------------------
Persistência (1 instância)
Persistência
----------------------------- */
const { init: initUserSettings, queuePatch } = useUserSettingsPersistence()
provide('queueUserSettingsPatch', queuePatch)
/* ----------------------------
Contexto (UID/Email/Tenant)
----------------------------- */
const sessionUid = ref(null)
const sessionEmail = ref(null)
async function loadSessionIdentity () {
try {
const { data, error } = await supabase.auth.getUser()
if (error) throw error
sessionUid.value = data?.user?.id || null
sessionEmail.value = data?.user?.email || null
} catch (e) {
sessionUid.value = null
sessionEmail.value = null
console.warn('[Topbar][identity] falhou:', e?.message || e)
}
}
const tenantId = computed(() => tenantStore.activeTenantId || null)
// ✅ tenta achar “nome/email” da clínica do jeito mais tolerante possível
const tenantName = computed(() => {
const t =
tenantStore.activeTenant ||
tenantStore.tenant ||
tenantStore.currentTenant ||
null
return (
t?.name ||
t?.nome ||
t?.display_name ||
t?.fantasy_name ||
t?.razao_social ||
null
)
})
const tenantEmail = computed(() => {
const t =
tenantStore.activeTenant ||
tenantStore.tenant ||
tenantStore.currentTenant ||
null
return (
t?.email ||
t?.clinic_email ||
t?.contact_email ||
null
)
})
const ctxItems = computed(() => {
const items = []
if (tenantName.value) items.push({ k: 'Clínica', v: tenantName.value })
if (tenantEmail.value) items.push({ k: 'Email', v: tenantEmail.value })
// ids (sempre úteis pra debug)
if (tenantId.value) items.push({ k: 'Tenant', v: tenantId.value })
if (sessionUid.value) items.push({ k: 'UID', v: sessionUid.value })
return items
})
/* ----------------------------
Fonte da verdade: DOM
----------------------------- */
function isDarkNow() {
function isDarkNow () {
return document.documentElement.classList.contains('app-dark')
}
function setDarkMode(shouldBeDark) {
function setDarkMode (shouldBeDark) {
const now = isDarkNow()
if (shouldBeDark !== now) toggleDarkMode()
}
async function waitForDarkFlip(before, timeoutMs = 900) {
async function waitForDarkFlip (before, timeoutMs = 900) {
const start = performance.now()
while (performance.now() - start < timeoutMs) {
await nextTick()
await new Promise((r) => requestAnimationFrame(r))
const now = isDarkNow()
if (now !== before) return now
}
return isDarkNow()
}
/* ----------------------------
Bootstrap: carrega e aplica
----------------------------- */
async function loadAndApplyUserSettings() {
async function loadAndApplyUserSettings () {
try {
const { data: u, error: uErr } = await supabase.auth.getUser()
if (uErr) throw uErr
@@ -74,27 +140,27 @@ async function loadAndApplyUserSettings() {
.maybeSingle()
if (error) throw error
if (!settings) {
console.log('[Topbar][bootstrap] sem user_settings ainda')
return
}
if (!settings) return
console.log('[Topbar][bootstrap] settings=', settings)
// dark/light
// 1) dark/light (DOM é a fonte da verdade)
if (settings.theme_mode) setDarkMode(settings.theme_mode === 'dark')
// layoutConfig
// 2) layoutConfig
if (settings.preset) layoutConfig.preset = settings.preset
if (settings.primary_color) layoutConfig.primary = settings.primary_color
if (settings.surface_color) layoutConfig.surface = settings.surface_color
if (settings.menu_mode) layoutConfig.menuMode = settings.menu_mode
// aplica tema via engine única
// 3) aplica engine UMA vez
applyThemeEngine(layoutConfig)
// aplica menu mode
try { changeMenuMode() } catch (e) {
// ✅ IMPORTANTE:
// changeMenuMode NÃO é só "setar menuMode".
// Ele reseta estados do sidebar/overlay/mobile e previne drift.
try {
changeMenuMode(layoutConfig.menuMode)
} catch (e) {
try { changeMenuMode({ value: layoutConfig.menuMode }) } catch {}
console.warn('[Topbar][bootstrap] changeMenuMode falhou:', e?.message || e)
}
} catch (e) {
@@ -102,45 +168,88 @@ async function loadAndApplyUserSettings() {
}
}
/* ----------------------------
Atalho topbar: Dark/Light
----------------------------- */
async function toggleDarkAndPersistSilently() {
async function toggleDarkAndPersistSilently () {
try {
const before = isDarkNow()
console.log('[Topbar][theme] click. before=', before ? 'dark' : 'light')
toggleDarkMode()
const after = await waitForDarkFlip(before)
const theme_mode = after ? 'dark' : 'light'
console.log('[Topbar][theme] after=', theme_mode, 'isDarkTheme=', !!isDarkTheme)
await queuePatch({ theme_mode }, { flushNow: true })
console.log('[Topbar][theme] saved theme_mode=', theme_mode)
} catch (e) {
console.error('[Topbar][theme] falhou:', e?.message || e)
}
}
/* ----------------------------
Plano (teu código intacto)
Plano (DEV) — popup menu
----------------------------- */
const tenantId = computed(() => tenantStore.activeTenantId || null)
const trocandoPlano = ref(false)
async function getPlanIdByKey(planKey) {
const { data, error } = await supabase.from('plans').select('id, key').eq('key', planKey).single()
const enablePlanToggle = computed(() => {
const flag = String(import.meta.env?.VITE_ENABLE_PLAN_TOGGLE || '').toLowerCase()
return Boolean(import.meta.env?.DEV) || flag === 'true'
})
const showPlanDevMenu = computed(() => {
return canSee('settings.view') && enablePlanToggle.value
})
const planMenu = ref()
const planMenuLoading = ref(false)
const planMenuTarget = ref(null) // 'therapist' | 'clinic' | null
const planMenuSub = ref(null) // subscription ativa (obj)
const planMenuPlans = ref([]) // plans ativos do target
async function getMyUserId () {
const { data, error } = await supabase.auth.getUser()
if (error) throw error
return data.id
const uid = data?.user?.id
if (!uid) throw new Error('Sessão inválida (sem user).')
return uid
}
async function getActiveSubscriptionByTenant(tid) {
// therapist subscription: user_id — sem filtro de tenant_id (pode estar preenchido)
async function getActiveTherapistSubscription () {
const uid = await getMyUserId()
const { data, error } = await supabase
.from('subscriptions')
.select('id, tenant_id, plan_id, status, created_at, updated_at')
.select('id, tenant_id, user_id, plan_id, status, updated_at')
.eq('user_id', uid)
.order('updated_at', { ascending: false })
.limit(10)
if (error) throw error
const list = data || []
if (!list.length) return null
const priority = (st) => {
const s = String(st || '').toLowerCase()
if (s === 'active') return 1
if (s === 'trialing') return 2
if (s === 'past_due') return 3
if (s === 'unpaid') return 4
if (s === 'incomplete') return 5
if (s === 'canceled' || s === 'cancelled') return 9
return 8
}
return list.slice().sort((a, b) => {
const pa = priority(a?.status)
const pb = priority(b?.status)
if (pa !== pb) return pa - pb
return new Date(b?.updated_at || 0) - new Date(a?.updated_at || 0)
})[0]
}
async function getActiveClinicSubscription () {
const tid = tenantId.value
if (!tid) return null
const { data, error } = await supabase
.from('subscriptions')
.select('id, tenant_id, user_id, plan_id, status, updated_at')
.eq('tenant_id', tid)
.eq('status', 'active')
.order('updated_at', { ascending: false })
@@ -151,69 +260,273 @@ async function getActiveSubscriptionByTenant(tid) {
return data || null
}
async function getPlanKeyById(planId) {
const { data, error } = await supabase.from('plans').select('key').eq('id', planId).single()
async function listActivePlansByTarget (target) {
const { data, error } = await supabase
.from('plans')
.select('id, key, target, is_active')
.eq('target', target)
.eq('is_active', true)
.order('key', { ascending: true })
if (error) throw error
return data.key
return data || []
}
async function alternarPlano() {
async function refreshEntitlementsAfterToggle (target) {
// ✅ aqui NÃO dá pra usar invalidate geral, porque precisamos dos dois caches
// mas durante toggle, é mais seguro forçar recarga do escopo que foi alterado.
if (target === 'clinic') {
const tid = tenantId.value
if (!tid) return
await entitlementsStore.loadForTenant(tid, { force: true })
return
}
// therapist
const uid = await getMyUserId()
await entitlementsStore.loadForUser(uid, { force: true })
}
/**
* ✅ Resolve a subscription ativa levando em conta a área da rota atual.
*
* Áreas de clínica (/admin, /supervisor) → contexto tenant_id (clinic)
* Demais áreas (/therapist, /editor, /portal, etc.) → contexto user_id (pessoal)
*
* Isso evita que um editor que também é membro de uma clínica veja o plano
* da clínica no botão DEV em vez do seu próprio plano.
*/
async function resolveActiveSubscriptionContext () {
const path = route.path || ''
const isClinicContext =
path.startsWith('/admin') ||
path.startsWith('/supervisor')
if (isClinicContext && tenantId.value) {
const clinicSub = await getActiveClinicSubscription()
if (clinicSub) return { sub: clinicSub, target: 'clinic' }
}
const therapistSub = await getActiveTherapistSubscription()
if (therapistSub) return { sub: therapistSub, target: 'therapist' }
// último fallback: clinic (caso não-clínica sem sub pessoal)
if (tenantId.value) {
const clinicSub = await getActiveClinicSubscription()
return { sub: clinicSub || null, target: clinicSub ? 'clinic' : null }
}
return { sub: null, target: null }
}
function normalizeKey (k) {
return String(k || '').trim()
}
// free primeiro, depois o resto por key
function sortPlansSmart (plans) {
const arr = [...(plans || [])]
arr.sort((a, b) => {
const ak = normalizeKey(a?.key).toLowerCase()
const bk = normalizeKey(b?.key).toLowerCase()
const aIsFree = ak.endsWith('_free') || ak === 'free'
const bIsFree = bk.endsWith('_free') || bk === 'free'
if (aIsFree && !bIsFree) return -1
if (!aIsFree && bIsFree) return 1
return ak.localeCompare(bk)
})
return arr
}
async function loadPlanMenuData () {
planMenuLoading.value = true
try {
const { sub, target } = await resolveActiveSubscriptionContext()
planMenuSub.value = sub
planMenuTarget.value = target
if (!sub?.id || !target) {
planMenuPlans.value = []
return
}
const plans = await listActivePlansByTarget(target)
planMenuPlans.value = sortPlansSmart(plans)
} finally {
planMenuLoading.value = false
}
}
const planMenuModel = computed(() => {
const sub = planMenuSub.value
const target = planMenuTarget.value
const plans = planMenuPlans.value || []
if (!sub?.id || !target) {
return [
{ label: 'Sem assinatura ativa', icon: 'pi pi-exclamation-triangle', disabled: true },
{ label: 'Não encontrei subscription ativa nem para therapist (user_id) nem para clinic (tenant_id).', disabled: true }
]
}
const currentPlanId = String(sub.plan_id || '')
const header = {
label: `Planos (${target})`,
icon: target === 'therapist' ? 'pi pi-user' : 'pi pi-building',
disabled: true
}
const subInfo = {
label: `Sub: ${String(sub.id).slice(0, 8)}… • Atual: ${String(currentPlanId).slice(0, 8)}`,
icon: 'pi pi-info-circle',
disabled: true
}
const items = []
let insertedSeparator = false
plans.forEach((p) => {
const isCurrent = String(p.id) === currentPlanId
const keyLower = String(p.key || '').toLowerCase()
const isFree = keyLower.endsWith('_free') || keyLower === 'free'
items.push({
label: isCurrent ? `${p.key} (atual)` : p.key,
icon: isCurrent ? 'pi pi-check' : (isFree ? 'pi pi-star' : 'pi pi-circle'),
disabled: isCurrent || planMenuLoading.value || trocandoPlano.value,
command: async () => {
await changePlanTo(p.id, p.key, target)
}
})
if (!insertedSeparator && isFree) {
items.push({ separator: true })
insertedSeparator = true
}
})
if (items.length && items[items.length - 1]?.separator) items.pop()
if (!plans.length) {
return [header, subInfo, { separator: true }, { label: 'Nenhum plano ativo encontrado', icon: 'pi pi-info-circle', disabled: true }]
}
return [header, subInfo, { separator: true }, ...items]
})
async function openPlanMenu (event) {
if (!showPlanDevMenu.value) return
try {
await loadPlanMenuData()
} catch (err) {
console.error('[PLANO][DEV menu] erro:', err?.message || err)
toast.add({
severity: 'error',
summary: 'Erro ao carregar planos',
detail: err?.message || 'Falha desconhecida.',
life: 5200
})
}
const anchorEl = planBtn.value?.$el || event?.currentTarget || event?.target
if (!anchorEl) {
planMenu.value?.toggle?.(event)
return
}
planMenu.value?.show?.({ currentTarget: anchorEl })
}
async function changePlanTo (newPlanId, newPlanKey, target) {
if (trocandoPlano.value) return
trocandoPlano.value = true
try {
const tid = tenantId.value
if (!tid) {
toast.add({ severity: 'warn', summary: 'Sem tenant ativo', detail: 'Selecione/entre em uma clínica (tenant) antes de trocar o plano.', life: 4500 })
return
}
const sub = await getActiveSubscriptionByTenant(tid)
if (!sub?.id) {
toast.add({ severity: 'warn', summary: 'Sem assinatura ativa', detail: 'Esse tenant ainda não tem subscription ativa. Ative via intenção/pagamento manual.', life: 5000 })
return
}
const atualKey = await getPlanKeyById(sub.plan_id)
const novoKey = atualKey === 'pro' ? 'free' : 'pro'
const novoPlanId = await getPlanIdByKey(novoKey)
const sub = planMenuSub.value
if (!sub?.id) throw new Error('Subscription inválida.')
const { error: rpcError } = await supabase.rpc('change_subscription_plan', {
p_subscription_id: sub.id,
p_new_plan_id: novoPlanId
p_new_plan_id: newPlanId
})
if (rpcError) throw rpcError
entitlementsStore.clear?.()
await entitlementsStore.fetch(tid, { force: true })
planMenuSub.value = { ...sub, plan_id: newPlanId }
toast.add({ severity: 'success', summary: 'Plano alternado', detail: `${String(atualKey).toUpperCase()}${String(novoKey).toUpperCase()}`, life: 3000 })
// ✅ recarrega o escopo certo (tenant ou user)
await refreshEntitlementsAfterToggle(target)
toast.add({
severity: 'success',
summary: 'Plano alterado (DEV)',
detail: `${String(newPlanKey).toUpperCase()} aplicado (${target})`,
life: 3200
})
} catch (err) {
console.error('[PLANO] Erro ao alternar:', err?.message || err)
toast.add({ severity: 'error', summary: 'Erro ao alternar plano', detail: err?.message || 'Falha desconhecida.', life: 5000 })
console.error('[PLANO] Erro ao trocar:', err?.message || err)
toast.add({
severity: 'error',
summary: 'Erro ao trocar plano',
detail: err?.message || 'Falha desconhecida.',
life: 6000
})
} finally {
trocandoPlano.value = false
}
}
async function logout() {
/* ----------------------------
Logout
----------------------------- */
async function logout () {
const tenant = useTenantStore()
const ent = useEntitlementsStore()
const tf = useTenantFeaturesStore()
try {
await supabase.auth.signOut()
} finally {
// limpa possíveis intenções guardadas
sessionStorage.removeItem('redirect_after_login')
sessionStorage.removeItem('intended_area')
tenant.reset()
ent.invalidate()
tf.invalidate()
// ✅ vai para HomeCards
router.replace('/')
// Use router.replace('/') e não push,
// assim o usuário não consegue voltar com o botão "voltar" para uma rota protegida.
sessionStorage.clear()
localStorage.clear()
router.replace('/auth/login')
}
}
/**
* ✅ Bootstrap entitlements (resolve “menu não alterna” sem depender do guard)
* - se tem tenant ativo => carrega tenant entitlements
* - senão => carrega user entitlements
*/
async function bootstrapEntitlements () {
try {
const uid = sessionUid.value || (await getMyUserId())
const tid = tenantId.value
if (tid) {
await entitlementsStore.loadForTenant(tid, { force: false, maxAgeMs: 60_000 })
} else if (uid) {
await entitlementsStore.loadForUser(uid, { force: false, maxAgeMs: 60_000 })
}
} catch (e) {
console.warn('[Topbar][entitlements bootstrap] falhou:', e?.message || e)
}
}
onMounted(async () => {
await initUserSettings()
await loadAndApplyUserSettings()
await loadSessionIdentity()
await bootstrapEntitlements()
})
</script>
@@ -228,10 +541,24 @@ onMounted(async () => {
<router-link to="/" class="layout-topbar-logo">
<svg viewBox="0 0 54 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- ... SVG gigante ... -->
<!-- ... SVG ... -->
</svg>
<span>SAKAI</span>
</router-link>
<div v-if="ctxItems.length" class="topbar-ctx">
<div class="topbar-ctx-row">
<span
v-for="(it, idx) in ctxItems"
:key="`${it.k}-${idx}`"
class="topbar-ctx-pill"
:title="`${it.k}: ${it.v}`"
>
<b class="topbar-ctx-k">{{ it.k }}:</b>
<span class="topbar-ctx-v">{{ it.v }}</span>
</span>
</div>
</div>
</div>
<div class="layout-topbar-actions">
@@ -277,13 +604,23 @@ onMounted(async () => {
<div class="layout-topbar-menu hidden lg:block">
<div class="layout-topbar-menu-content">
<Button
label="Plano"
icon="pi pi-sync"
v-if="showPlanDevMenu"
ref="planBtn"
label="Plano (DEV)"
icon="pi pi-sliders-h"
severity="contrast"
outlined
:loading="trocandoPlano"
:disabled="trocandoPlano"
@click="alternarPlano"
:loading="planMenuLoading || trocandoPlano"
:disabled="planMenuLoading || trocandoPlano"
@click="openPlanMenu"
/>
<Menu
ref="planMenu"
:model="planMenuModel"
popup
appendTo="body"
:baseZIndex="3000"
/>
<button type="button" class="layout-topbar-action">
@@ -309,3 +646,46 @@ onMounted(async () => {
</div>
</div>
</template>
<style scoped>
.topbar-ctx {
display: flex;
align-items: center;
margin-left: 0.75rem;
max-width: min(62vw, 980px);
}
.topbar-ctx-row {
display: flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
}
.topbar-ctx-pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.45rem;
border-radius: 999px;
border: 1px solid var(--surface-border);
background: var(--surface-card);
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02);
max-width: 320px;
}
.topbar-ctx-k {
font-size: 0.75rem;
opacity: 0.7;
white-space: nowrap;
}
.topbar-ctx-v {
font-size: 0.75rem;
opacity: 0.95;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 240px;
}
</style>
+172
View File
@@ -0,0 +1,172 @@
<!-- src/layout/ConfiguracoesPage.vue -->
<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
const route = useRoute()
const router = useRouter()
const secoes = [
{
key: 'agenda',
label: 'Agenda',
desc: 'Horários semanais, exceções, duração e intervalo padrão.',
icon: 'pi pi-calendar',
to: '/configuracoes/agenda',
tags: ['Horários', 'Exceções', 'Duração']
},
// Ative quando criar as rotas/páginas
// {
// key: 'clinica',
// label: 'Clínica',
// desc: 'Padrões clínicos, status e preferências de atendimento.',
// icon: 'pi pi-heart',
// to: '/configuracoes/clinica',
// tags: ['Status', 'Modelos', 'Preferências']
// },
// {
// key: 'intake',
// label: 'Cadastros & Intake',
// desc: 'Link externo, campos do formulário e mensagens padrão.',
// icon: 'pi pi-file-edit',
// to: '/configuracoes/intake',
// tags: ['Formulário', 'Campos', 'Textos']
// },
// {
// key: 'conta',
// label: 'Conta',
// desc: 'Perfil, segurança e preferências da conta.',
// icon: 'pi pi-user',
// to: '/configuracoes/conta',
// tags: ['Perfil', 'Segurança', 'Preferências']
// }
]
const activeTo = computed(() => {
const p = route.path || ''
const hit = secoes.find(s => p.startsWith(s.to))
return hit?.to || '/configuracoes/agenda'
})
function ir(to) {
if (!to) return
if (route.path !== to) router.push(to)
}
</script>
<template>
<div class="p-4">
<!-- HEADER CONCEITUAL -->
<div class="mb-4 overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="relative p-5">
<!-- blobs sutis -->
<div class="pointer-events-none absolute inset-0 opacity-80">
<div class="absolute -top-16 -right-16 h-52 w-52 rounded-full bg-emerald-400/10 blur-3xl" />
<div class="absolute top-10 -left-24 h-60 w-60 rounded-full bg-indigo-400/10 blur-3xl" />
<div class="absolute bottom-0 right-24 h-44 w-44 rounded-full bg-fuchsia-400/10 blur-3xl" />
</div>
<div class="relative">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-900 text-2xl font-semibold leading-none">Configurações</div>
<div class="text-600 mt-2 max-w-2xl">
Defina como sua clínica funciona: agenda, cadastros e preferências. Tudo no mesmo lugar sem espalhar opções pelo sistema.
</div>
</div>
<Button
label="Voltar"
icon="pi pi-arrow-left"
severity="secondary"
outlined
class="hidden md:inline-flex"
@click="router.back()"
/>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-12 gap-4">
<!-- SIDEBAR (seções) -->
<div class="col-span-12 lg:col-span-4 xl:col-span-3">
<Card class="h-full">
<template #title>
<div class="flex items-center gap-2">
<i class="pi pi-cog" />
<span>Seções</span>
</div>
</template>
<template #content>
<div class="flex flex-col gap-2">
<button
v-for="s in secoes"
:key="s.key"
type="button"
class="w-full text-left p-3 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-card)] hover:bg-[var(--surface-hover)] transition flex items-start justify-between gap-3"
:class="activeTo === s.to ? 'ring-1 ring-primary/40 border-primary/40' : ''"
@click="ir(s.to)"
>
<div class="flex gap-3">
<div class="mt-1">
<i :class="[s.icon, 'text-lg']" style="opacity:.85" />
</div>
<div>
<div class="text-900 font-medium leading-none">{{ s.label }}</div>
<div class="text-600 text-sm mt-2 leading-snug">{{ s.desc }}</div>
<div v-if="s.tags?.length" class="mt-3 flex flex-wrap gap-2">
<span
v-for="t in s.tags"
:key="t"
class="text-xs px-2 py-1 rounded-full border border-[var(--surface-border)] text-600"
>
{{ t }}
</span>
</div>
</div>
</div>
<i class="pi pi-angle-right mt-1" style="opacity:.55" />
</button>
<Divider class="my-2" />
<Button
label="Voltar"
icon="pi pi-arrow-left"
severity="secondary"
outlined
class="w-full md:hidden"
@click="router.back()"
/>
</div>
</template>
</Card>
<!-- Card pequeno atalhos opcional -->
<div class="mt-4 hidden lg:block">
<Card>
<template #content>
<div class="text-900 font-medium">Dica</div>
<div class="text-600 text-sm mt-2 leading-relaxed">
Comece pela <b>Agenda</b>. É ela que tempo ao prontuário: sessão marcada sessão realizada evolução.
</div>
</template>
</Card>
</div>
</div>
<!-- CONTEÚDO (seção selecionada) -->
<div class="col-span-12 lg:col-span-8 xl:col-span-9">
<!-- Aqui entra /configuracoes/agenda etc -->
<router-view />
</div>
</div>
</div>
</template>
+86 -44
View File
@@ -1,15 +1,17 @@
<!-- src/layout/ConfiguracoesPage.vue -->
<script setup>
import { computed } from 'vue'
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import Card from 'primevue/card'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
const route = useRoute()
const router = useRouter()
// ── Hero sticky ────────────────────────────────────────────
const headerEl = ref(null)
const headerSentinelRef = ref(null)
const headerStuck = ref(false)
let _observer = null
const secoes = [
{
key: 'agenda',
@@ -57,41 +59,51 @@ function ir(to) {
if (!to) return
if (route.path !== to) router.push(to)
}
onMounted(() => {
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`
_observer = new IntersectionObserver(
([entry]) => { headerStuck.value = !entry.isIntersecting },
{ threshold: 0, rootMargin }
)
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value)
})
onBeforeUnmount(() => { _observer?.disconnect() })
</script>
<template>
<div class="p-4">
<!-- HEADER CONCEITUAL -->
<div class="mb-4 overflow-hidden rounded-2xl border border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="relative p-5">
<!-- blobs sutis -->
<div class="pointer-events-none absolute inset-0 opacity-80">
<div class="absolute -top-16 -right-16 h-52 w-52 rounded-full bg-emerald-400/10 blur-3xl" />
<div class="absolute top-10 -left-24 h-60 w-60 rounded-full bg-indigo-400/10 blur-3xl" />
<div class="absolute bottom-0 right-24 h-44 w-44 rounded-full bg-fuchsia-400/10 blur-3xl" />
</div>
<!-- Sentinel -->
<div ref="headerSentinelRef" class="cfg-sentinel" />
<div class="relative">
<div class="flex items-start justify-between gap-3">
<div>
<div class="text-900 text-2xl font-semibold leading-none">Configurações</div>
<div class="text-600 mt-2 max-w-2xl">
Defina como sua clínica funciona: agenda, cadastros e preferências. Tudo no mesmo lugar sem espalhar opções pelo sistema.
</div>
</div>
<!-- Hero sticky -->
<div ref="headerEl" class="cfg-hero mb-4" :class="{ 'cfg-hero--stuck': headerStuck }">
<div class="cfg-hero__blobs" aria-hidden="true">
<div class="cfg-hero__blob cfg-hero__blob--1" />
<div class="cfg-hero__blob cfg-hero__blob--2" />
<div class="cfg-hero__blob cfg-hero__blob--3" />
</div>
<Button
label="Voltar"
icon="pi pi-arrow-left"
severity="secondary"
outlined
class="hidden md:inline-flex"
@click="router.back()"
/>
</div>
<div class="cfg-hero__row1">
<div class="cfg-hero__brand">
<div class="cfg-hero__icon"><i class="pi pi-cog text-lg" /></div>
<div class="min-w-0">
<div class="cfg-hero__title">Configurações</div>
<div class="cfg-hero__sub">Defina como sua agenda e clínica funcionam</div>
</div>
</div>
<div class="hidden xl:flex items-center gap-2 shrink-0">
<Button label="Voltar" icon="pi pi-arrow-left" severity="secondary" outlined class="rounded-full" @click="router.back()" />
</div>
<div class="flex xl:hidden items-center shrink-0">
<Button icon="pi pi-arrow-left" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="router.back()" />
</div>
</div>
</div>
<div class="pt-0">
<div class="grid grid-cols-12 gap-4">
<!-- SIDEBAR (seções) -->
@@ -151,18 +163,6 @@ function ir(to) {
</div>
</template>
</Card>
<!-- Card pequeno atalhos opcional -->
<div class="mt-4 hidden lg:block">
<Card>
<template #content>
<div class="text-900 font-medium">Dica</div>
<div class="text-600 text-sm mt-2 leading-relaxed">
Comece pela <b>Agenda</b>. É ela que tempo ao prontuário: sessão marcada sessão realizada evolução.
</div>
</template>
</Card>
</div>
</div>
<!-- CONTEÚDO (seção selecionada) -->
@@ -173,3 +173,45 @@ function ir(to) {
</div>
</div>
</template>
<style scoped>
.cfg-sentinel { height: 1px; }
.cfg-hero {
position: sticky;
top: var(--layout-sticky-top, 56px);
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.25rem 1.5rem;
}
.cfg-hero--stuck {
margin-left: 0; margin-right: 0;
border-top-left-radius: 0; border-top-right-radius: 0;
}
.cfg-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.cfg-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
.cfg-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(52,211,153,0.10); }
.cfg-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(99,102,241,0.09); }
.cfg-hero__blob--3 { width: 14rem; height: 14rem; bottom: -2rem; right: 8rem; background: rgba(217,70,239,0.07); }
.cfg-hero__row1 {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1rem;
}
.cfg-hero__brand {
display: flex; align-items: center; gap: 0.75rem;
flex: 1; min-width: 0;
}
.cfg-hero__icon {
display: grid; place-items: center;
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem; flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
}
.cfg-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
.cfg-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
</style>
-7
View File
@@ -1,7 +0,0 @@
<template>
<AppShellLayout />
</template>
<script setup>
import AppShellLayout from './AppShellLayout.vue'
</script>
-7
View File
@@ -1,7 +0,0 @@
<template>
<AppShellLayout />
</template>
<script setup>
import AppShellLayout from './AppShellLayout.vue'
</script>
+4
View File
@@ -0,0 +1,4 @@
<template><AppLayout area="admin" /></template>
<script setup>
import AppLayout from '../AppLayout.vue'
</script>
+4
View File
@@ -0,0 +1,4 @@
<template><AppLayout area="portal" /></template>
<script setup>
import AppLayout from '../AppLayout.vue'
</script>
+4
View File
@@ -0,0 +1,4 @@
<template><AppLayout area="therapist" /></template>
<script setup>
import AppLayout from '../AppLayout.vue'
</script>
+80 -13
View File
@@ -1,38 +1,76 @@
import { computed, reactive } from 'vue'
// ── resolve variant salvo no localStorage ───────────────────
function _loadVariant () {
try {
const v = localStorage.getItem('layout_variant')
if (v === 'rail' || v === 'classic') return v
} catch {}
return 'classic'
}
const layoutConfig = reactive({
preset: 'Aura',
primary: 'emerald',
surface: null,
darkTheme: false,
menuMode: 'static'
menuMode: 'static',
variant: _loadVariant() // 'classic' | 'rail'
})
const layoutState = reactive({
staticMenuInactive: false,
overlayMenuActive: false,
mobileMenuActive: false, // ✅ ADICIONA (estava faltando)
mobileMenuActive: false,
profileSidebarVisible: false,
configSidebarVisible: false,
sidebarExpanded: false,
menuHoverActive: false,
anchored: false,
activeMenuItem: null,
activePath: null
activePath: null,
// ── Layout 2 (rail) ─────────────────────────────────────
railSectionKey: null, // qual seção está ativa no rail
railPanelOpen: false // painel lateral expandido
})
/**
* ✅ Fonte da verdade do dark:
* - DOM class: .app-dark (usado pelo PrimeUI/PrimeVue)
* - layoutConfig.darkTheme: refletir o DOM (pra UI reagir)
*
* 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”.
*/
let _syncedDarkFromDomOnce = false
function syncDarkFromDomOnce () {
if (_syncedDarkFromDomOnce) return
_syncedDarkFromDomOnce = true
try {
layoutConfig.darkTheme = document.documentElement.classList.contains('app-dark')
} catch {}
}
export function useLayout () {
// ✅ garante coerência sempre que alguém usar useLayout()
syncDarkFromDomOnce()
const executeDarkModeToggle = () => {
layoutConfig.darkTheme = !layoutConfig.darkTheme
// ✅ garante consistência (não depende do estado anterior do DOM)
document.documentElement.classList.toggle('app-dark', layoutConfig.darkTheme)
}
const toggleDarkMode = () => {
if (!document.startViewTransition) {
executeDarkModeToggle()
return
}
document.startViewTransition(() => executeDarkModeToggle(event))
}
const executeDarkModeToggle = () => {
layoutConfig.darkTheme = !layoutConfig.darkTheme
document.documentElement.classList.toggle('app-dark')
// ✅ não usa "event" (undefined) e mantém transição suave quando suportado
document.startViewTransition(() => executeDarkModeToggle())
}
const isDesktop = () => window.innerWidth > 991
@@ -57,6 +95,8 @@ export function useLayout () {
const hideMobileMenu = () => {
layoutState.mobileMenuActive = false
layoutState.overlayMenuActive = false
layoutState.menuHoverActive = false
}
// ✅ use isso ao navegar: mantém menu aberto no desktop, fecha só no mobile
@@ -68,15 +108,41 @@ export function useLayout () {
}
}
const changeMenuMode = (event) => {
layoutConfig.menuMode = event.value
/**
* ✅ aceita:
* - changeMenuMode({ value: 'static' })
* - changeMenuMode('static')
*
* Motivo: você chama isso de lugares diferentes (Topbar, Configurator, Profile).
*/
const changeMenuMode = (eventOrValue) => {
const nextMode = typeof eventOrValue === 'string'
? eventOrValue
: eventOrValue?.value
// ✅ não deixa setar undefined / vazio
if (!nextMode) return
layoutConfig.menuMode = nextMode
// ✅ reset consistente (evita drift quando alterna overlay/static)
layoutState.staticMenuInactive = false
layoutState.overlayMenuActive = false
layoutState.mobileMenuActive = false
layoutState.sidebarExpanded = false
layoutState.menuHoverActive = false
layoutState.anchored = false
}
const setVariant = (v) => {
if (v !== 'classic' && v !== 'rail') return
layoutConfig.variant = v
try { localStorage.setItem('layout_variant', v) } catch {}
// reset rail state ao trocar
layoutState.railSectionKey = null
layoutState.railPanelOpen = false
}
const isDarkTheme = computed(() => layoutConfig.darkTheme)
const hasOpenOverlay = computed(() => layoutState.overlayMenuActive)
@@ -88,9 +154,10 @@ export function useLayout () {
toggleConfigSidebar,
toggleMenu,
hideMobileMenu,
closeMenuOnNavigate, // ✅ exporta
closeMenuOnNavigate,
changeMenuMode,
setVariant,
isDesktop,
hasOpenOverlay
}
}
}
@@ -0,0 +1,272 @@
<!-- src/layout/concepcoes/ex-header-conceitual.vue -->
<!-- ===========================================================
TEMPLATE DE REFERÊNCIA Hero Header Sticky
Padrão utilizado em: AgendaTerapeutaPage, ProfilePage
===========================================================
ESTRUTURA GERAL
1. Sentinel (div 1px) + IntersectionObserver detecta quando o header
"cola" no topo da viewport e ativa a classe --stuck.
2. Hero div com position:sticky, top: var(--layout-sticky-top, 56px).
Layout 1 (classic): 56px (topbar fixed). Layout 2 (rail): 0px (topbar no fluxo).
3. Dois estados:
- Expandido : blobs decorativos visíveis, subtítulo, filtros e busca
- Colado : comprimido (max-height), apenas brand + ações essenciais
4. Responsividade:
- 1200px : todos os controles inline (ag-hero__desktop-controls)
- <1200px : botão "Ações" abre Menu popup (ag-hero__mobile-controls)
O menu mobile DEVE incluir "Buscar" abrindo um Dialog com input + resultados.
SCRIPT (refs + onMounted)
const headerSentinelRef = ref(null)
const headerEl = ref(null)
const headerStuck = ref(false)
const headerMenuRef = ref(null)
const headerMenuItems = computed(() => [
{ label: 'Ação principal', icon: 'pi pi-plus', command: () => acaoPrincipal() },
{ label: 'Buscar', icon: 'pi pi-search', command: () => { searchModalOpen.value = true } },
{ separator: true },
{ label: 'Recarregar', icon: 'pi pi-refresh', command: () => refetch() },
{ label: 'Configurações', icon: 'pi pi-cog', command: () => goSettings() },
])
onMounted(() => {
if (headerSentinelRef.value) {
const io = new IntersectionObserver(
([entry]) => { headerStuck.value = !entry.isIntersecting },
{ rootMargin: `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px` }
)
io.observe(headerSentinelRef.value)
}
})
CSS (scoped)
Ver seção <style> ao final deste arquivo.
BUSCA MOBILE
O Dialog de busca deve ter o InputText DENTRO do dialog (não resultados),
com autofocus, compartilhando o mesmo v-model="search" do header desktop.
Estados: sem texto instrução | buscando loading | sem resultado | lista.
=========================================================== -->
<!-- SENTINEL -->
<div ref="headerSentinelRef" class="pg-sentinel" />
<!-- HERO HEADER -->
<div ref="headerEl" class="pg-hero mb-4" :class="{ 'pg-hero--stuck': headerStuck }">
<!-- Blobs decorativos (some automaticamente quando colado via overflow:hidden) -->
<div class="pg-hero__blobs" aria-hidden="true">
<div class="pg-hero__blob pg-hero__blob--1" />
<div class="pg-hero__blob pg-hero__blob--2" />
<div class="pg-hero__blob pg-hero__blob--3" />
</div>
<!-- Linha 1: brand + controles -->
<div class="pg-hero__row1">
<!-- Brand: ícone + título + subtítulo (some quando colado) -->
<div class="pg-hero__brand">
<div class="pg-hero__icon">
<i class="pi pi-ICON_AQUI text-lg" />
</div>
<div class="min-w-0">
<div class="pg-hero__title">Título da Página</div>
<div v-if="!headerStuck" class="pg-hero__sub">Subtítulo ou data/contexto atual</div>
</div>
</div>
<!-- Controles desktop (1200px) -->
<div class="pg-hero__desktop-controls">
<!-- Grupo de busca (oculto quando colado) -->
<div v-if="!headerStuck" class="w-[260px]">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-search" />
<InputText v-model="search" class="w-full" autocomplete="off" @keyup.enter="searchModalOpen = true" />
</IconField>
<label>Buscar</label>
</FloatLabel>
</div>
<!-- Ações secundárias (ocultas quando colado, ex: filtros contextuais) -->
<div v-if="!headerStuck" class="flex items-center gap-2">
<!-- SplitButton, Dropdown, etc. -->
</div>
<!-- Ações primárias (sempre visíveis) -->
<div class="flex items-center gap-1">
<Button icon="pi pi-plus" class="h-9 w-9 rounded-full" title="Ação principal" @click="acaoPrincipal" />
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Recarregar" @click="refetch" />
<Button icon="pi pi-cog" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Configurações" @click="goSettings" />
</div>
</div>
<!-- Botão mobile (<1200px) -->
<div class="pg-hero__mobile-controls">
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full"
@click="(e) => headerMenuRef.toggle(e)" />
<Menu ref="headerMenuRef" :model="headerMenuItems" :popup="true" />
</div>
</div>
<!-- Linha 2: filtros/KPIs oculta quando colado -->
<div v-if="!headerStuck" class="pg-hero__row2">
<div class="flex flex-wrap items-center gap-2">
<!-- SelectButtons, Tags de filtro, KPIs clicáveis, etc. -->
<Button class="!rounded-full" outlined severity="secondary">
<span class="flex items-center gap-2">
<i class="pi pi-list" /> Total: <b>{{ total }}</b>
</span>
</Button>
</div>
<!-- Chips de filtros ativos + limpar -->
<div v-if="hasActiveFilters" class="flex items-center gap-2">
<Tag value="Filtro ativo" severity="secondary" />
<Button label="Limpar" icon="pi pi-filter-slash" severity="danger" outlined size="small" class="!rounded-full" @click="clearFilters" />
</div>
</div>
</div>
<!-- DIALOG DE BUSCA (mobile + desktop) -->
<!--
REGRA: o InputText de busca FICA DENTRO do dialog.
Isso garante boa UX no mobile (teclado não cobre resultados).
O v-model="search" é o mesmo do header desktop resultados sincronizados.
-->
<Dialog
v-model:visible="searchModalOpen"
modal
header="Buscar"
:style="{ width: '96vw', maxWidth: '720px' }"
:breakpoints="{ '960px': '92vw', '640px': '96vw' }"
:draggable="false"
>
<div class="flex flex-col gap-3">
<FloatLabel variant="on">
<IconField>
<InputIcon class="pi pi-search" />
<InputText v-model="search" class="w-full" autocomplete="off" autofocus />
</IconField>
<label>Nome, e-mail, título</label>
</FloatLabel>
<Divider class="my-0" />
<div v-if="!searchTrim" class="text-color-secondary text-sm py-2">
Digite para buscar.
</div>
<div v-else-if="searchLoading" class="text-color-secondary text-sm">Buscando</div>
<div v-else-if="!searchResults.length" class="text-color-secondary text-sm">
Nenhum resultado para "<b>{{ searchTrim }}</b>".
</div>
<div v-else class="flex flex-col gap-2 max-h-[60vh] overflow-auto pr-1">
<div class="text-xs text-color-secondary mb-1">{{ searchResults.length }} resultado(s)</div>
<button
v-for="r in searchResults" :key="r.id"
class="text-left rounded-2xl border border-[var(--surface-border)] p-3 transition hover:shadow-sm"
@click="gotoResultFromModal(r)"
>
<div class="font-medium truncate">{{ r.titulo || r.nome }}</div>
<div class="mt-1 text-xs opacity-70 truncate">{{ r.subtitulo }}</div>
</button>
</div>
</div>
<template #footer>
<Button label="Fechar" icon="pi pi-times" text @click="searchModalOpen = false" />
<Button v-if="searchTrim" label="Limpar" icon="pi pi-eraser" severity="secondary" outlined class="rounded-full" @click="search = ''; searchModalOpen = false" />
</template>
</Dialog>
<!-- ══════════════════════════════════════════════════════════
CSS DE REFERÊNCIA (copiar para <style scoped> da página)
Prefixo: "pg-" substitua pelo prefixo da página (ag-, prof-, etc.)
-->
<style scoped>
/* Sentinel */
.pg-sentinel { height: 1px; }
/* Hero base */
.pg-hero {
position: sticky;
top: var(--layout-sticky-top, 56px); /* 56px Layout1 / 0px Layout2 (Rail) */
z-index: 20;
overflow: hidden;
border-radius: 1.75rem;
border: 1px solid var(--surface-border);
background: var(--surface-card);
padding: 1.25rem 1.5rem;
max-height: 600px;
}
/* Estado colado */
.pg-hero--stuck {
border-top-left-radius: 0;
border-top-right-radius: 0;
max-height: 64px;
padding-top: 0.625rem;
padding-bottom: 0.625rem;
}
/* Blobs decorativos */
.pg-hero__blobs { position: absolute; inset: 0; pointer-events: none; overflow: hidden; }
.pg-hero__blob { position: absolute; border-radius: 50%; filter: blur(70px); }
.pg-hero__blob--1 { width: 18rem; height: 18rem; top: -4rem; right: -3rem; background: rgba(99,102,241,0.12); }
.pg-hero__blob--2 { width: 20rem; height: 20rem; top: 0.5rem; left: -5rem; background: rgba(52,211,153,0.09); }
.pg-hero__blob--3 { width: 14rem; height: 14rem; bottom: -2rem; right: 22%; background: rgba(217,70,239,0.08); }
/* Linha 1 */
.pg-hero__row1 {
position: relative; z-index: 1;
display: flex; align-items: center; gap: 1rem;
}
.pg-hero__brand {
display: flex; align-items: center; gap: 0.75rem;
flex-shrink: 0; min-width: 0;
}
.pg-hero__icon {
display: grid; place-items: center;
width: 2.5rem; height: 2.5rem; border-radius: 0.875rem;
flex-shrink: 0;
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
color: var(--p-primary-500, #6366f1);
}
.pg-hero__title {
font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em;
color: var(--text-color); white-space: nowrap;
}
.pg-hero__sub {
font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.pg-hero__desktop-controls {
flex: 1; display: flex; align-items: center;
justify-content: flex-end; gap: 0.75rem; flex-wrap: wrap;
}
.pg-hero__mobile-controls { display: none; }
/* Linha 2 */
.pg-hero__row2 {
position: relative; z-index: 1;
display: flex; flex-wrap: wrap; align-items: center;
justify-content: space-between; gap: 0.75rem;
margin-top: 0.875rem; padding-top: 0.75rem;
border-top: 1px solid var(--surface-border);
}
/* Mobile < 1200px */
@media (max-width: 1199px) {
.pg-hero__desktop-controls { display: none; }
.pg-hero__mobile-controls { display: flex; margin-left: auto; }
.pg-hero__row2 { display: none; }
}
</style>
File diff suppressed because it is too large Load Diff