Ajuste Layout, Dashboard Terapeuta, Timeline, Suporte técnico, Documentação e FAQ

This commit is contained in:
Leonardo
2026-03-15 19:46:06 -03:00
parent ee09b30987
commit f66f6f3fde
21 changed files with 24146 additions and 721 deletions

View File

@@ -16,7 +16,8 @@
"Bash(cd \"/d/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai\" && C:/Users/lmnohama/AppData/Local/Programs/Python/Python310/python.exe -c \"\nimport zipfile\nimport xml.etree.ElementTree as ET\n\nfor fname in ['spec-wizard.docx', 'spec-v2.docx']:\n print\\('=== ' + fname + ' ==='\\)\n try:\n with zipfile.ZipFile\\(fname, 'r'\\) as z:\n with z.open\\('word/document.xml'\\) as f:\n tree = ET.parse\\(f\\)\n root = tree.getroot\\(\\)\n texts = []\n for para in root.iter\\('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}p'\\):\n parts = []\n for t in para.iter\\('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t'\\):\n if t.text:\n parts.append\\(t.text\\)\n line = ''.join\\(parts\\)\n texts.append\\(line\\)\n print\\('\\\\n'.join\\(texts\\)\\)\n except Exception as e:\n print\\('Error: ' + str\\(e\\)\\)\n print\\(\\)\n\")",
"Bash(C:/Users/lmnohama/AppData/Local/Programs/Python/Python310/python.exe -c \"\nimport sys\nwith open\\('/d/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/DBS/2026-03-12/schema.sql', 'r', encoding='utf-8'\\) as f:\n lines = f.readlines\\(\\)\nprint\\(f'Total lines: {len\\(lines\\)}'\\)\n\" 2>&1)",
"Bash(C:/Users/lmnohama/AppData/Local/Programs/Python/Python310/python.exe -c \"\nimport sys\nfpath = 'D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/DBS/2026-03-12/schema.sql'\nwith open\\(fpath, 'r', encoding='utf-8'\\) as f:\n lines = f.readlines\\(\\)\nsys.stdout.buffer.write\\(\\('Total lines: ' + str\\(len\\(lines\\)\\) + '\\\\n'\\).encode\\('utf-8'\\)\\)\n\" 2>&1)",
"Bash(find /d/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai -type f \\\\\\( -name \"*convenio*\" -o -name \"*Convenio*\" \\\\\\) 2>/dev/null | head -20)"
"Bash(find /d/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai -type f \\\\\\( -name \"*convenio*\" -o -name \"*Convenio*\" \\\\\\) 2>/dev/null | head -20)",
"Bash(find:*)"
]
}
}

21933
DBS/2026-03-15/schema.sql Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -78,10 +78,25 @@ export async function bootstrapUserSettings({
if (error || !settings) return
// layout variant (rail / classic)
if (settings.layout_variant === 'rail' || settings.layout_variant === 'classic') {
setVariant(settings.layout_variant)
// layout variant: respeita a preferência já gravada no localStorage.
// Se localStorage está vazio, só aplica 'rail' do banco (confirma o padrão).
// Nunca aplica 'classic' automaticamente quando não há preferência local —
// dado antigo no banco não deve sobrescrever o padrão do app.
const _lsV = (() => {
try {
const v = localStorage.getItem('layout_variant')
return (v === 'rail' || v === 'classic') ? v : null
} catch { return null }
})()
if (_lsV !== null) {
// localStorage já tem valor → aplica ele (garante coerência com layoutConfig)
if (_lsV !== (settings.layout_variant ?? _lsV)) setVariant(_lsV)
} else if (settings.layout_variant === 'rail') {
// localStorage vazio + banco tem 'rail' → aplica e grava no localStorage
setVariant('rail')
}
// localStorage vazio + banco tem 'classic' → mantém padrão 'rail' (não aplica)
// menu mode
if (settings.menu_mode && settings.menu_mode !== layoutConfig.menuMode) {

View File

@@ -1,159 +1,166 @@
@use 'mixins' as *;
/* ── Sidebar container ─────────────────────────────────────── */
.layout-sidebar {
position: fixed;
width: 20rem;
height: calc(100vh - 8rem);
z-index: 999;
overflow-y: auto;
user-select: none;
top: 6rem;
left: 2rem;
transition:
transform var(--layout-section-transition-duration),
left var(--layout-section-transition-duration);
background-color: var(--surface-overlay);
border-radius: var(--content-border-radius);
padding: 0.5rem 1.5rem;
position: fixed;
width: 20rem;
height: calc(100vh - 56px);
z-index: 999;
overflow-y: auto;
overflow-x: hidden;
user-select: none;
top: 56px;
left: 0;
transition:
transform var(--layout-section-transition-duration),
left var(--layout-section-transition-duration);
background-color: var(--surface-card);
border-radius: 0;
padding: 0;
scrollbar-width: thin;
scrollbar-color: var(--surface-border) transparent;
&::-webkit-scrollbar { width: 4px; }
&::-webkit-scrollbar-track { background: transparent; }
&::-webkit-scrollbar-thumb { background: var(--surface-border); border-radius: 4px; }
}
/* ── Menu list ─────────────────────────────────────────────── */
.layout-menu {
margin: 0;
padding: 0.5rem 0 2rem;
list-style-type: none;
/* ── Section header ─────────────────────────────────────── */
.layout-root-menuitem {
> .layout-menuitem-root-text {
font-size: 0.6rem;
text-transform: uppercase;
font-weight: 800;
letter-spacing: 0.11em;
color: var(--text-color-secondary);
opacity: 0.45;
padding: 1.1rem 1.25rem 0.3rem;
margin: 0;
}
> a { display: none; }
}
/* ── Active toggler ─────────────────────────────────────── */
a.active-menuitem > .layout-submenu-toggler,
li.active-menuitem > a .layout-submenu-toggler {
transform: rotate(-180deg);
}
/* ── Links ──────────────────────────────────────────────── */
ul {
margin: 0;
padding: 0;
padding: 0 0.625rem;
list-style-type: none;
.layout-root-menuitem {
> .layout-menuitem-root-text {
font-size: 0.857rem;
text-transform: uppercase;
font-weight: 700;
color: var(--text-color);
margin: 0.75rem 0;
}
> a {
display: none;
}
}
a {
user-select: none;
display: flex;
align-items: center;
position: relative;
outline: 0 none;
color: var(--text-color-secondary);
cursor: pointer;
padding: 0.48rem 0.75rem;
border-radius: 8px;
font-size: 0.825rem;
font-weight: 500;
line-height: 1.4;
transition: background 0.12s, color 0.12s;
gap: 0;
&.active-menuitem {
> .layout-submenu-toggler {
transform: rotate(-180deg);
}
}
}
li.active-menuitem {
> a {
.layout-submenu-toggler {
transform: rotate(-180deg);
}
}
.layout-menuitem-icon {
font-size: 0.85rem;
flex-shrink: 0;
opacity: 0.6;
margin-right: 0.6rem;
transition: opacity 0.12s;
width: 1rem;
text-align: center;
}
.layout-menuitem-text {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.layout-submenu-toggler {
font-size: 68%;
margin-left: auto;
padding-left: 0.35rem;
transition: transform var(--element-transition-duration);
opacity: 0.4;
flex-shrink: 0;
}
&.active-route {
font-weight: 600;
color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 9%, transparent);
.layout-menuitem-icon { opacity: 1; }
.layout-submenu-toggler { opacity: 0.6; }
}
&:hover {
background: var(--surface-ground);
color: var(--text-color);
.layout-menuitem-icon { opacity: 0.85; }
}
&:focus {
@include focused-inset();
}
}
/* ── Sub-items ────────────────────────────────────────── */
ul {
margin: 0;
padding: 0;
list-style-type: none;
padding: 0;
overflow: hidden;
border-radius: 0;
a {
display: flex;
align-items: center;
position: relative;
outline: 0 none;
color: var(--text-color);
cursor: pointer;
padding: 0.75rem 1rem;
border-radius: var(--content-border-radius);
transition:
box-shadow var(--element-transition-duration);
li a {
font-size: 0.8rem;
padding: 0.4rem 0.75rem 0.4rem 2.1rem;
font-weight: 400;
opacity: 0.9;
}
.layout-menuitem-icon {
margin-right: 0.5rem;
}
.layout-submenu-toggler {
font-size: 75%;
margin-left: auto;
transition: transform var(--element-transition-duration);
}
&.active-route {
font-weight: 700;
color: var(--primary-color);
}
&:hover {
background-color: var(--surface-hover);
}
&:focus {
@include focused-inset();
}
}
ul {
overflow: hidden;
border-radius: var(--content-border-radius);
li {
a {
margin-left: 1rem;
}
li {
a {
margin-left: 2rem;
}
li {
a {
margin-left: 2.5rem;
}
li {
a {
margin-left: 3rem;
}
li {
a {
margin-left: 3.5rem;
}
li {
a {
margin-left: 4rem;
}
}
}
}
}
}
}
}
li li a { padding-left: 2.9rem; }
li li li a { padding-left: 3.5rem; }
li li li li a { padding-left: 4rem; }
li li li li li a { padding-left: 4.5rem; }
li li li li li li a { padding-left: 5rem; }
}
}
}
/* ── Submenu transition ────────────────────────────────────── */
.layout-submenu-enter-from,
.layout-submenu-leave-to {
max-height: 0;
max-height: 0;
}
.layout-submenu-enter-to,
.layout-submenu-leave-from {
max-height: 1000px;
max-height: 1000px;
}
.layout-submenu-leave-active {
overflow: hidden;
transition: max-height 0.45s cubic-bezier(0, 1, 0, 1);
overflow: hidden;
transition: max-height 0.35s cubic-bezier(0, 1, 0, 1);
}
.layout-submenu-enter-active {
overflow: hidden;
transition: max-height 1s ease-in-out;
overflow: hidden;
transition: max-height 0.45s ease-in-out;
}

View File

@@ -3,7 +3,7 @@ import { computed, inject } from 'vue'
import { useLayout } from '@/layout/composables/layout'
import { primaryColors, surfaces, presetOptions, applyThemeEngine } from '@/theme/theme.options'
const { layoutConfig, isDarkTheme, changeMenuMode } = useLayout()
const { layoutConfig, isDarkTheme, changeMenuMode, setVariant } = useLayout()
// ✅ vem do AppTopbar (mesma instância)
const queuePatch = inject('queueUserSettingsPatch', null)
@@ -101,7 +101,8 @@ function updateColors (type, item) {
<SelectButton v-model="presetModel" :options="presetOptions" :allowEmpty="false" />
</div>
<div class="flex flex-col gap-2">
<!-- Menu Mode: visível apenas no Layout Clássico -->
<div v-show="layoutConfig.variant === 'classic'" class="flex flex-col gap-2">
<span class="text-sm text-muted-color font-semibold">Menu Mode</span>
<SelectButton
v-model="menuModeModel"
@@ -111,6 +112,98 @@ function updateColors (type, item) {
optionValue="value"
/>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm text-muted-color font-semibold">Layout</span>
<div class="flex flex-col gap-1">
<!-- Layout Rail -->
<button
type="button"
class="layout-option"
:class="{ 'layout-option--active': layoutConfig.variant === 'rail' }"
@click="setVariant('rail')"
>
<i
:class="layoutConfig.variant === 'rail' ? 'pi pi-check-circle' : 'pi pi-circle'"
class="layout-option__icon"
/>
<span class="layout-option__label">Layout Rail</span>
<span v-if="layoutConfig.variant === 'rail'" class="layout-option__badge layout-option__badge--default">Padrão</span>
</button>
<!-- Layout Clássico -->
<button
type="button"
class="layout-option"
:class="{ 'layout-option--active': layoutConfig.variant === 'classic' }"
@click="setVariant('classic')"
>
<i
:class="layoutConfig.variant === 'classic' ? 'pi pi-check-circle' : 'pi pi-circle'"
class="layout-option__icon"
/>
<span class="layout-option__label">Layout Clássico</span>
</button>
</div>
</div>
</div>
</div>
</template>
</template>
<style scoped>
.layout-option {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.45rem 0.6rem;
border-radius: var(--border-radius, 6px);
border: 1px solid var(--surface-border);
background: var(--surface-ground);
font-size: 0.8rem;
cursor: pointer;
text-align: left;
transition: border-color 0.15s, background 0.15s;
width: 100%;
}
.layout-option:hover {
border-color: var(--primary-color);
}
.layout-option--active {
border-color: var(--primary-color);
background: color-mix(in srgb, var(--primary-color) 8%, transparent);
}
.layout-option__icon {
font-size: 0.85rem;
color: var(--text-color-secondary);
flex-shrink: 0;
}
.layout-option--active .layout-option__icon {
color: var(--primary-color);
}
.layout-option__label {
flex: 1;
font-weight: 500;
color: var(--text-color);
}
.layout-option__badge {
font-size: 0.6rem;
font-weight: 700;
letter-spacing: 0.01em;
padding: 0.1rem 0.45rem;
border-radius: 999px;
white-space: nowrap;
flex-shrink: 0;
}
.layout-option__badge--default {
background: var(--primary-color);
color: var(--primary-color-text, #fff);
}
</style>

View File

@@ -3,13 +3,13 @@ import { useLayout } from '@/layout/composables/layout'
import { computed, onMounted, onBeforeUnmount, provide, watch } from 'vue'
import { useRoute } from 'vue-router'
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 AjudaDrawer from '@/components/AjudaDrawer.vue'
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 AppRailSidebar from './AppRailSidebar.vue'
import AjudaDrawer from '@/components/AjudaDrawer.vue'
import { fetchDocsForPath, useAjuda } from '@/composables/useAjuda'
@@ -27,7 +27,6 @@ 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)
@@ -60,10 +59,8 @@ async function revalidateAfterSessionRefresh () {
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 })
@@ -74,20 +71,16 @@ async function revalidateAfterSessionRefresh () {
}
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()
}
// Dispara busca de docs de ajuda sempre que a rota muda
watch(() => route.path, (path) => fetchDocsForPath(path), { immediate: true })
onMounted(() => {
@@ -100,18 +93,19 @@ onBeforeUnmount(() => {
</script>
<template>
<!-- Fullscreen: setup wizard (sem sidebar/topbar/footer) -->
<!-- Fullscreen -->
<template v-if="route.meta?.fullscreen">
<router-view />
<Toast />
</template>
<!-- Layout 2: Rail + Painel + Main (full-width) -->
<template v-else-if="layoutConfig.variant === 'rail' && isDesktop()">
<!-- Layout Rail -->
<template v-else-if="layoutConfig.variant === 'rail'">
<div class="l2-root">
<!-- Rail de ícones: oculto em mobile ( 1200px) via CSS -->
<AppRail />
<div class="l2-body">
<AppRailTopbar />
<AppTopbar />
<div class="l2-content">
<AppRailPanel />
<div class="l2-main" :style="ajudaPushStyle">
@@ -120,11 +114,19 @@ onBeforeUnmount(() => {
</div>
</div>
</div>
<!-- Sidebar mobile exclusiva do Rail -->
<AppRailSidebar />
<!-- Overlay escuro ao abrir sidebar mobile -->
<div
v-if="layoutState.mobileMenuActive"
class="l2-mobile-overlay"
@click="hideMobileMenu"
/>
<AjudaDrawer />
<Toast />
</template>
<!-- Layout 1: Clássico -->
<!-- Layout Clássico melhorado -->
<template v-else>
<div class="layout-wrapper" :class="containerClass">
<AppTopbar />
@@ -142,8 +144,62 @@ onBeforeUnmount(() => {
</template>
</template>
<style>
/* ──────────────────────────────────────────────
LAYOUT CLÁSSICO — ajustes globais (não scoped)
para sobrescrever o tema PrimeVue/Sakai
────────────────────────────────────────────── */
/* ── Sidebar — sempre abaixo da topbar fixed (56px) ────────
z-index: 999 para flutuar sobre o conteúdo em overlay.
Topbar (z-index 1000) fica sempre acessível acima da sidebar. */
.layout-sidebar {
position: fixed !important;
top: 56px !important;
left: 0 !important;
height: calc(100vh - 56px) !important;
border-radius: 0 !important;
padding: 0 !important;
box-shadow: 2px 0 6px rgba(0,0,0,.06) !important;
border-right: 1px solid var(--surface-border) !important;
z-index: 999;
}
/* ── Topbar no layout Clássico — sempre tela toda, acima da sidebar */
.layout-wrapper .rail-topbar {
z-index: 1000 !important;
}
/* ── Conteúdo — margem esquerda por modo ───────────────────
Static ativo : afasta da sidebar
Static inativo: sem margem
Overlay : sem margem (sidebar flutua sobre o conteúdo) */
.layout-main-container {
margin-left: 20rem !important;
padding-left: 0 !important;
padding-top: 56px !important;
}
.layout-overlay .layout-main-container,
.layout-static-inactive .layout-main-container {
margin-left: 0 !important;
}
@media (max-width: 1200px) {
.layout-main-container {
margin-left: 0 !important;
}
}
/* ── Overlay: hambúrguer sempre visível ─────────────────────
Em overlay a sidebar não ocupa espaço fixo — o botão precisa
estar disponível em qualquer largura de tela. */
.layout-overlay .rail-topbar__hamburger {
display: grid !important;
}
</style>
<style scoped>
/* ─── Layout 2 ───────────────────────────────────────────── */
/* ─── Layout Rail (inalterado) ────────────────── */
.l2-root {
display: flex;
width: 100vw;
@@ -152,7 +208,6 @@ onBeforeUnmount(() => {
background: var(--surface-ground);
}
/* Coluna direita do rail: topbar + conteúdo */
.l2-body {
flex: 1;
min-width: 0;
@@ -160,9 +215,9 @@ onBeforeUnmount(() => {
flex-direction: column;
height: 100vh;
overflow: hidden;
padding-top: 56px; /* compensa a topbar fixed */
}
/* Linha: painel lateral + main */
.l2-content {
flex: 1;
min-height: 0;
@@ -170,13 +225,35 @@ onBeforeUnmount(() => {
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;
}
/* Rail de ícones: oculto em mobile */
@media (max-width: 1200px) {
.l2-root :deep(.rail) {
display: none;
}
/* Painel lateral: também oculto em mobile (substituído pelo AppRailSidebar) */
.l2-content :deep(.rp) {
display: none;
}
}
/* Overlay escuro ao abrir sidebar mobile no Rail */
.l2-mobile-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 98;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
</style>

View File

@@ -334,7 +334,7 @@ function onSearchFocus () {
<template>
<div class="flex flex-col h-full">
<!-- 🔎 TOPO FIXO -->
<div ref="searchWrapEl" class="pb-2 border-b border-[var(--surface-border)] bg-[var(--surface-card)]">
<div ref="searchWrapEl" class="px-3 pt-3 pb-2 border-b border-[var(--surface-border)] bg-[var(--surface-card)]">
<div class="relative">
<div
aria-hidden="true"

View File

@@ -56,7 +56,12 @@ function isSameRoute (current, 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 + '/')
if (cur === tar) return true
// Prefix match apenas para paths com 2+ segmentos (ex: /therapist/patients).
// Paths de 1 segmento (ex: /therapist) só ativam em match exato,
// evitando que o Dashboard fique ativo em todas as sub-rotas.
const segments = tar.split('/').filter(Boolean)
return segments.length >= 2 && cur.startsWith(tar + '/')
}
function hasActiveDescendant (node, currentPath) {
@@ -195,7 +200,7 @@ async function irCadastroCompleto () {
:is="item.to && !item.items ? 'router-link' : 'a'"
v-bind="item.to && !item.items ? { to: item.to } : { href: item.url }"
@click="itemClick($event, item)"
:class="[item.class, isBlocked ? 'opacity-60 cursor-pointer' : '']"
:class="[item.class, isBlocked ? 'opacity-60 cursor-pointer' : '', { 'active-route': isActive && !item.items }]"
:target="item.target"
tabindex="0"
@mouseenter="onMouseEnter"

View File

@@ -27,11 +27,20 @@ function isLocked (item) {
try { return !entitlements.has(item.feature) } catch { return false }
}
function toPath (to) {
if (!to) return ''
if (typeof to === 'string') return to
try { return router.resolve(to).path || '' } catch { return '' }
}
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 + '/')
const p = toPath(item.to)
if (!p) return false
if (active === p) return true
const segments = p.split('/').filter(Boolean)
return segments.length >= 2 && active.startsWith(p + '/')
}
function navigate (item) {

View File

@@ -0,0 +1,312 @@
<!-- src/layout/AppRailSidebar.vue Drawer mobile para Layout Rail -->
<script setup>
import { computed, ref, watch } 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, hideMobileMenu } = useLayout()
const entitlements = useEntitlementsStore()
const router = useRouter()
const route = useRoute()
const sections = 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-circle',
items: s.items
}))
})
// Seções expandidas no accordion
const openSections = ref([])
// Ao abrir o drawer, expande a seção ativa (ou a primeira)
watch(() => layoutState.mobileMenuActive, (open) => {
if (!open) return
if (openSections.value.length === 0 && sections.value.length > 0) {
const activeKey = layoutState.railSectionKey
openSections.value = [activeKey || sections.value[0].key]
}
})
function isSectionOpen (key) {
return openSections.value.includes(key)
}
function toggleSection (key) {
const idx = openSections.value.indexOf(key)
if (idx >= 0) {
openSections.value.splice(idx, 1)
} else {
openSections.value.push(key)
}
}
function isLocked (item) {
if (!item.proBadge || !item.feature) return false
try { return !entitlements.has(item.feature) } catch { return false }
}
function toPath (to) {
if (!to) return ''
if (typeof to === 'string') return to
try { return router.resolve(to).path || '' } catch { return '' }
}
function isActive (item) {
const active = String(layoutState.activePath || route.path || '')
if (!item.to) return false
const p = toPath(item.to)
if (!p) return false
if (active === p) return true
const segments = p.split('/').filter(Boolean)
return segments.length >= 2 && active.startsWith(p + '/')
}
function navigate (item) {
if (isLocked(item)) {
router.push({ name: 'upgrade', query: { feature: item.feature || '' } })
hideMobileMenu()
return
}
if (item.to) {
layoutState.activePath = toPath(item.to)
router.push(item.to)
hideMobileMenu()
}
}
// Fecha ao navegar
watch(() => route.path, () => hideMobileMenu())
</script>
<template>
<Transition name="rs-slide">
<aside v-if="layoutState.mobileMenuActive" class="rs">
<!-- Header -->
<div class="rs__head">
<span class="rs__brand">Agência PSI</span>
<button class="rs__close" aria-label="Fechar menu" @click="hideMobileMenu">
<i class="pi pi-times" />
</button>
</div>
<!-- Nav -->
<nav class="rs__nav">
<template v-for="section in sections" :key="section.key">
<!-- Cabeçalho da seção -->
<button
class="rs__sec"
:class="{ 'rs__sec--open': isSectionOpen(section.key) }"
@click="toggleSection(section.key)"
>
<i :class="section.icon" class="rs__sec-icon" />
<span class="rs__sec-label">{{ section.label }}</span>
<i class="pi pi-chevron-down rs__sec-arrow" />
</button>
<!-- Items da seção -->
<div v-show="isSectionOpen(section.key)" class="rs__items">
<template v-for="item in section.items" :key="item.to || item.label">
<!-- Sub-grupo -->
<template v-if="item.items?.length">
<div class="rs__group-label">{{ item.label }}</div>
<button
v-for="child in item.items"
:key="child.to || child.label"
class="rs__item"
:class="{
'rs__item--active': isActive(child),
'rs__item--locked': isLocked(child)
}"
@click="navigate(child)"
>
<i v-if="child.icon" :class="child.icon" class="rs__item-icon" />
<span>{{ child.label }}</span>
<span v-if="isLocked(child)" class="rs__pro">PRO</span>
</button>
</template>
<!-- Item folha -->
<button
v-else
class="rs__item"
:class="{
'rs__item--active': isActive(item),
'rs__item--locked': isLocked(item)
}"
@click="navigate(item)"
>
<i v-if="item.icon" :class="item.icon" class="rs__item-icon" />
<span>{{ item.label }}</span>
<span v-if="isLocked(item)" class="rs__pro">PRO</span>
</button>
</template>
</div>
</template>
</nav>
</aside>
</Transition>
</template>
<style scoped>
.rs {
position: fixed;
top: 0;
left: 0;
width: 280px;
height: 100vh;
z-index: 99; /* acima do overlay (98), abaixo da topbar (100) quando necessário */
background: var(--surface-card);
border-right: 1px solid var(--surface-border);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Header ──────────────────────────────────────────────── */
.rs__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);
}
.rs__brand {
font-size: 0.9rem;
font-weight: 700;
color: var(--text-color);
}
.rs__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;
}
.rs__close:hover {
background: var(--surface-ground);
color: var(--text-color);
}
/* ── Nav list ─────────────────────────────────────────────── */
.rs__nav {
flex: 1;
overflow-y: auto;
padding: 8px;
scrollbar-width: thin;
scrollbar-color: var(--surface-border) transparent;
}
/* ── Section accordion ───────────────────────────────────── */
.rs__sec {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 9px 10px;
border-radius: 9px;
border: none;
background: transparent;
color: var(--text-color);
cursor: pointer;
font-size: 0.83rem;
font-weight: 600;
text-align: left;
transition: background 0.13s;
}
.rs__sec:hover { background: var(--surface-ground); }
.rs__sec-icon {
font-size: 0.9rem;
color: var(--text-color-secondary);
flex-shrink: 0;
}
.rs__sec-label { flex: 1; }
.rs__sec-arrow {
font-size: 0.65rem;
color: var(--text-color-secondary);
transition: transform 0.2s;
}
.rs__sec--open .rs__sec-arrow { transform: rotate(180deg); }
/* ── Items container ─────────────────────────────────────── */
.rs__items {
padding-left: 10px;
padding-bottom: 4px;
}
.rs__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: 8px 10px 4px;
}
.rs__item {
width: 100%;
display: flex;
align-items: center;
gap: 9px;
padding: 7px 10px;
border-radius: 8px;
border: none;
background: transparent;
color: var(--text-color-secondary);
cursor: pointer;
font-size: 0.82rem;
font-weight: 500;
text-align: left;
transition: background 0.13s, color 0.13s;
}
.rs__item:hover {
background: var(--surface-ground);
color: var(--text-color);
}
.rs__item--active {
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
color: var(--primary-color);
font-weight: 600;
}
.rs__item--locked { opacity: 0.55; }
.rs__item-icon {
font-size: 0.82rem;
flex-shrink: 0;
opacity: 0.75;
}
.rs__pro {
font-size: 0.58rem;
font-weight: 800;
text-transform: uppercase;
padding: 1px 5px;
border-radius: 4px;
border: 1px solid var(--surface-border);
color: var(--text-color-secondary);
}
/* ── Slide-in da esquerda ────────────────────────────────── */
.rs-slide-enter-active,
.rs-slide-leave-active {
transition: transform 0.25s cubic-bezier(0.22, 1, 0.36, 1);
}
.rs-slide-enter-from,
.rs-slide-leave-to {
transform: translateX(-100%);
}
</style>

View File

@@ -1,171 +0,0 @@
<!-- 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'
import { useAjuda } from '@/composables/useAjuda'
const { toggleDarkMode, isDarkTheme } = useLayout()
const { queuePatch } = useUserSettingsPersistence()
const tenantStore = useTenantStore()
const { openDrawer: openAjudaDrawer } = useAjuda()
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">
<!-- Ajuda -->
<button
type="button"
class="rail-topbar__btn"
title="Ajuda"
@click="openAjudaDrawer"
>
<i class="pi pi-question-circle" />
</button>
<!-- 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>

View File

@@ -16,7 +16,10 @@ import { useRoleGuard } from '@/composables/useRoleGuard'
const { canSee } = useRoleGuard()
import { useAjuda } from '@/composables/useAjuda'
const { openDrawer: openAjudaDrawer } = useAjuda()
const { openDrawer: openAjudaDrawer, closeDrawer: closeAjudaDrawer, drawerOpen: ajudaDrawerOpen } = useAjuda()
function toggleAjuda () {
ajudaDrawerOpen.value ? closeAjudaDrawer() : openAjudaDrawer()
}
import { useUserSettingsPersistence } from '@/composables/useUserSettingsPersistence'
import { applyThemeEngine } from '@/theme/theme.options'
@@ -27,7 +30,7 @@ const toast = useToast()
const entitlementsStore = useEntitlementsStore()
const tenantStore = useTenantStore()
const { toggleMenu, toggleDarkMode, isDarkTheme, layoutConfig, changeMenuMode } = useLayout()
const { toggleMenu, toggleDarkMode, isDarkTheme, layoutConfig, changeMenuMode, isRailMobile } = useLayout()
const router = useRouter()
const route = useRoute()
@@ -60,45 +63,9 @@ async function loadSessionIdentity () {
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
)
})
// ✅ Admin Dev
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 })
@@ -160,11 +127,14 @@ async function loadAndApplyUserSettings () {
// ✅ 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)
// No layout Rail, não deve ser chamado — ele não usa menuMode.
if (layoutConfig.variant !== 'rail') {
try {
changeMenuMode(layoutConfig.menuMode)
} catch (e) {
try { changeMenuMode({ value: layoutConfig.menuMode }) } catch {}
console.warn('[Topbar][bootstrap] changeMenuMode falhou:', e?.message || e)
}
}
} catch (e) {
console.error('[Topbar][bootstrap] erro:', e?.message || e)
@@ -197,6 +167,19 @@ const showPlanDevMenu = computed(() => {
return canSee('settings.view') && enablePlanToggle.value
})
const ctxMenu = ref()
const ctxMenuModel = computed(() =>
ctxItems.value.length
? ctxItems.value.map(it => ({
label: `${it.k}: ${it.v}`,
icon: it.k === 'Tenant' ? 'pi pi-building' : 'pi pi-user'
}))
: [{ label: 'Sem contexto', icon: 'pi pi-info-circle', disabled: true }]
)
function openCtxMenu (event) {
ctxMenu.value?.toggle?.(event)
}
const planMenu = ref()
const planMenuLoading = ref(false)
const planMenuTarget = ref(null) // 'therapist' | 'clinic' | null
@@ -535,145 +518,194 @@ onMounted(async () => {
<template>
<Toast />
<div class="layout-topbar">
<div class="layout-topbar-logo-container">
<button class="layout-menu-button layout-topbar-action" @click="toggleMenu">
<header class="rail-topbar">
<!-- Esquerda -->
<div class="rail-topbar__left">
<!-- Hamburguer: aparece em 1200px no Rail -->
<button class="layout-menu-button rail-topbar__btn rail-topbar__hamburger" @click="toggleMenu">
<i class="pi pi-bars"></i>
</button>
<router-link to="/" class="layout-topbar-logo">
<svg viewBox="0 0 54 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- ... SVG ... -->
</svg>
<span>SAKAI</span>
<router-link to="/" class="layout-topbar-logo ml-3">
<span>Agência PSI</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>
<!-- Pills: visíveis apenas em > 1200px -->
<div class="topbar-ctx-row ml-2">
<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>
<!-- Botão Tenant/UID: visível apenas em 1200px -->
<button
type="button"
class="rail-topbar__btn topbar-ctx-btn ml-2"
title="Tenant / UID"
@click="openCtxMenu"
>
<i class="pi pi-id-card" />
<span class="topbar-ctx-btn__label">Tenant / UID</span>
</button>
<Menu
ref="ctxMenu"
:model="ctxMenuModel"
popup
appendTo="body"
:baseZIndex="3000"
/>
</div>
<div class="layout-topbar-actions">
<div class="layout-config-menu">
<button type="button" class="layout-topbar-action" @click="toggleDarkAndPersistSilently">
<i :class="['pi', { 'pi-moon': isDarkTheme, 'pi-sun': !isDarkTheme }]"></i>
</button>
<div class="relative">
<button
v-styleclass="{
selector: '@next',
enterFromClass: 'hidden',
enterActiveClass: 'p-anchored-overlay-enter-active',
leaveToClass: 'hidden',
leaveActiveClass: 'p-anchored-overlay-leave-active',
hideOnOutsideClick: true
}"
type="button"
class="layout-topbar-action layout-topbar-action-highlight"
>
<i class="pi pi-palette"></i>
</button>
<AppConfigurator />
</div>
</div>
<button
class="layout-topbar-menu-button layout-topbar-action"
v-styleclass="{
selector: '@next',
enterFromClass: 'hidden',
enterActiveClass: 'p-anchored-overlay-enter-active',
leaveToClass: 'hidden',
leaveActiveClass: 'p-anchored-overlay-leave-active',
hideOnOutsideClick: true
}"
<!-- Ações -->
<div class="rail-topbar__actions">
<!-- Plan Dev Button -->
<Button
v-if="showPlanDevMenu"
ref="planBtn"
outlined
:loading="planMenuLoading || trocandoPlano"
:disabled="planMenuLoading || trocandoPlano"
@click="openPlanMenu"
class="rail-topbar__btn"
>
<i class="pi pi-ellipsis-v"></i>
<i class="pi pi-sliders-h" />
</Button>
<Menu
ref="planMenu"
:model="planMenuModel"
popup
appendTo="body"
:baseZIndex="3000"
/>
<!-- Ajuda -->
<button
type="button"
class="rail-topbar__btn"
:class="{ 'rail-topbar__btn--active': ajudaDrawerOpen }"
:title="ajudaDrawerOpen ? 'Fechar ajuda' : 'Ajuda'"
@click="toggleAjuda"
>
<i class="pi pi-question-circle" />
</button>
<div class="layout-topbar-menu hidden lg:block">
<div class="layout-topbar-menu-content">
<Button
v-if="showPlanDevMenu"
ref="planBtn"
label="Plano (DEV)"
icon="pi pi-sliders-h"
severity="contrast"
outlined
:loading="planMenuLoading || trocandoPlano"
:disabled="planMenuLoading || trocandoPlano"
@click="openPlanMenu"
/>
<!-- Dark mode -->
<button
type="button"
class="rail-topbar__btn"
:title="isDarkTheme ? 'Modo claro' : 'Modo escuro'"
@click="toggleDarkAndPersistSilently"
>
<i :class="['pi', { 'pi-moon': isDarkTheme, 'pi-sun': !isDarkTheme }]"></i>
</button>
<Menu
ref="planMenu"
:model="planMenuModel"
popup
appendTo="body"
:baseZIndex="3000"
/>
<Button
icon="pi pi-question-circle"
label="Ajuda"
severity="secondary"
outlined
class="ajuda-btn"
@click="openAjudaDrawer"
/>
<button type="button" class="layout-topbar-action">
<i class="pi pi-calendar"></i>
<span>Calendar</span>
</button>
<button type="button" class="layout-topbar-action">
<i class="pi pi-inbox"></i>
<span>Messages</span>
</button>
<button type="button" class="layout-topbar-action">
<i class="pi pi-user"></i>
<span>Profile</span>
</button>
<button type="button" class="layout-topbar-action" @click="logout">
<i class="pi pi-sign-out"></i>
</button>
</div>
<!-- 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>
<button type="button" class="rail-topbar__btn" @click="logout">
<i class="pi pi-sign-out"></i>
</button>
</div>
</div>
</header>
</template>
<style scoped>
.topbar-ctx {
.rail-topbar {
position: fixed;
top: 0;
left: 0;
right: 0;
height: 56px;
flex-shrink: 0;
display: flex;
align-items: center;
margin-left: 0.75rem;
max-width: min(62vw, 980px);
justify-content: space-between;
padding: 0 1.25rem;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-card);
z-index: 100;
}
.rail-topbar__left {
display: flex;
align-items: center;
min-width: 0;
}
/* Hamburguer: visível apenas em ≤ 1200px
!important necessário para sobrescrever CSS do tema Sakai (.layout-menu-button) */
.rail-topbar__hamburger {
display: none !important;
}
@media (max-width: 1200px) {
.rail-topbar__hamburger {
display: grid !important;
}
}
.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);
}
.rail-topbar__btn--active {
background: var(--surface-ground);
color: var(--primary-color);
}
.config-panel {
z-index: 200;
}
.topbar-ctx-row {
display: flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
}
.topbar-ctx-pill {
display: inline-flex;
align-items: center;
@@ -683,28 +715,28 @@ onMounted(async () => {
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;
}
.ajuda-btn {
border-radius: 999px;
/* Botão Tenant/UID: só em ≤ 1200px */
.topbar-ctx-btn {
display: none !important;
width: auto !important;
border-radius: 999px !important;
padding: 0 0.65rem !important;
gap: 0.35rem;
font-size: 0.8rem;
padding: 0.3rem 0.8rem;
height: 2rem;
border: 1px solid var(--surface-border) !important;
}
.topbar-ctx-v {
font-size: 0.75rem;
opacity: 0.95;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 240px;
.topbar-ctx-btn__label {
font-size: 0.8rem;
color: var(--text-color-secondary);
}
@media (max-width: 1200px) {
.topbar-ctx-row {
display: none !important;
}
.topbar-ctx-btn {
display: inline-flex !important;
}
}
</style>

View File

@@ -6,7 +6,7 @@ function _loadVariant () {
const v = localStorage.getItem('layout_variant')
if (v === 'rail' || v === 'classic') return v
} catch {}
return 'classic'
return 'rail'
}
const layoutConfig = reactive({
@@ -31,7 +31,10 @@ const layoutState = reactive({
activePath: null,
// ── Layout 2 (rail) ─────────────────────────────────────
railSectionKey: null, // qual seção está ativa no rail
railPanelOpen: false // painel lateral expandido
railPanelOpen: false, // painel lateral expandido
// ── variant dirty: true quando o usuário mudou o variant
// mas ainda não salvou — impede que loadUserSettings reverta
_variantDirty: false
})
/**
@@ -75,12 +78,21 @@ export function useLayout () {
const isDesktop = () => window.innerWidth > 991
// breakpoint do botão hamburguer no Rail (≤ 1200px)
const isRailMobile = () => window.innerWidth <= 1200
const toggleMenu = () => {
// No Rail, o botão hamburguer (≤1200px) controla a sidebar mobile
if (layoutConfig.variant === 'rail') {
layoutState.mobileMenuActive = !layoutState.mobileMenuActive
return
}
// Layout clássico — comportamento original
if (isDesktop()) {
if (layoutConfig.menuMode === 'static') {
layoutState.staticMenuInactive = !layoutState.staticMenuInactive
}
if (layoutConfig.menuMode === 'overlay') {
layoutState.overlayMenuActive = !layoutState.overlayMenuActive
}
@@ -134,13 +146,15 @@ export function useLayout () {
layoutState.anchored = false
}
const setVariant = (v) => {
const setVariant = (v, { fromUser = true } = {}) => {
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
// marca que o usuário fez uma escolha explícita (não restauração do DB)
if (fromUser) layoutState._variantDirty = true
}
const isDarkTheme = computed(() => layoutConfig.darkTheme)
@@ -158,6 +172,7 @@ export function useLayout () {
changeMenuMode,
setVariant,
isDesktop,
isRailMobile,
hasOpenOverlay
}
}

View File

@@ -7,26 +7,27 @@
export default [
{
label: 'Editor',
label: 'Início',
items: [
// ======================================================
// 📊 DASHBOARD
// ======================================================
{ label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/editor' },
{ label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/editor' }
]
},
// ======================================================
// 📚 CONTEÚDO
// ======================================================
{ label: 'Cursos', icon: 'pi pi-fw pi-book', to: '/editor/cursos' },
{ label: 'Módulos', icon: 'pi pi-fw pi-th-large', to: '/editor/modulos' },
{ label: 'Publicados', icon: 'pi pi-fw pi-check-circle', to: '/editor/publicados' },
{
label: 'Conteúdo',
items: [
{ label: 'Cursos', icon: 'pi pi-fw pi-book', to: '/editor/cursos' },
{ label: 'Módulos', icon: 'pi pi-fw pi-th-large', to: '/editor/modulos' },
{ label: 'Publicados', icon: 'pi pi-fw pi-check-circle', to: '/editor/publicados' }
]
},
// ======================================================
// 👤 CONTA
// ======================================================
{
label: 'Conta',
items: [
{ label: 'Meu plano', icon: 'pi pi-fw pi-credit-card', to: '/editor/meu-plano' },
{ label: 'Meu Perfil', icon: 'pi pi-fw pi-user', to: '/account/profile' },
{ label: 'Segurança', icon: 'pi pi-fw pi-shield', to: '/account/security' }
{ label: 'Meu Perfil', icon: 'pi pi-fw pi-user', to: '/account/profile' },
{ label: 'Segurança', icon: 'pi pi-fw pi-shield', to: '/account/security' }
]
}
]

View File

@@ -1,52 +1,26 @@
// src/navigation/menus/portal.menu.js
export default [
{
label: 'Paciente',
label: 'Início',
items: [
{ label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/portal' }
]
},
{
label: 'Minhas sessões',
items: [
{ label: 'Sessões', icon: 'pi pi-fw pi-calendar', to: '/portal/sessoes' }
]
},
{
label: 'Conta',
items: [
// ======================
// ✅ Básico (sempre)
// ======================
{ label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/portal' },
{ label: 'Minhas sessões', icon: 'pi pi-fw pi-user', to: '/portal/sessoes' },
// ✅ Conta é global, não do portal
{ label: 'Meu plano', icon: 'pi pi-fw pi-credit-card', to: '/portal/meu-plano' },
{ label: 'Minha Conta', icon: 'pi pi-fw pi-user', to: '/account/profile' },
{ label: 'Segurança', icon: 'pi pi-fw pi-shield', to: '/account/security' }
// =====================================================
// 🔒 PRO (exemplos futuros no portal do paciente)
// =====================================================
// A lógica do AppMenuItem que ajustamos suporta:
// - feature: 'chave_da_feature'
// - proBadge: true -> aparece "PRO" quando bloqueado
//
// ⚠️ Só descomente quando a rota existir.
//
// 1) Página pública de agendamento (se você criar um “link do paciente”)
// {
// label: 'Agendar online',
// icon: 'pi pi-fw pi-globe',
// to: '/portal/online-scheduling',
// feature: 'online_scheduling.public',
// proBadge: true
// },
//
// 2) Documentos/Arquivos (muito comum em SaaS clínico)
// {
// label: 'Documentos',
// icon: 'pi pi-fw pi-file',
// to: '/portal/documents',
// feature: 'patient_documents',
// proBadge: true
// },
//
// 3) Teleatendimento / Sala (se for ter)
// {
// label: 'Sala de atendimento',
// icon: 'pi pi-fw pi-video',
// to: '/portal/telehealth',
// feature: 'telehealth',
// proBadge: true
// }
{ label: 'Minha Conta', icon: 'pi pi-fw pi-user', to: '/account/profile' },
{ label: 'Segurança', icon: 'pi pi-fw pi-shield', to: '/account/security' }
]
}
]

View File

@@ -16,62 +16,58 @@ export default function saasMenu (sessionCtx, opts = {}) {
return [
{
label: 'SaaS',
icon: 'pi pi-building',
path: '/saas',
label: 'Início',
items: [
{ label: 'Dashboard', icon: 'pi pi-chart-bar', to: '/saas' },
{ label: 'Dashboard', icon: 'pi pi-fw pi-chart-bar', to: '/saas' }
]
},
{
label: 'Planos',
items: [
{ label: 'Planos e Preços', icon: 'pi pi-fw pi-list', to: '/saas/plans' },
{ label: 'Vitrine Pública', icon: 'pi pi-fw pi-megaphone', to: '/saas/plans-public' },
{ label: 'Recursos', icon: 'pi pi-fw pi-bolt', to: '/saas/features' },
{ label: 'Controle de Recursos',icon: 'pi pi-fw pi-th-large', to: '/saas/plan-features' },
{ label: 'Limites por Plano', icon: 'pi pi-fw pi-sliders-h', to: '/saas/plan-limits' }
]
},
{
label: 'Assinaturas',
items: [
{ label: 'Listagem', icon: 'pi pi-fw pi-list', to: '/saas/subscriptions' },
{ label: 'Intenções', icon: 'pi pi-fw pi-inbox', to: '/saas/subscription-intents' },
{ label: 'Histórico', icon: 'pi pi-fw pi-history', to: '/saas/subscription-events' },
{
label: 'Planos',
icon: 'pi pi-star',
path: '/saas/plans',
items: [
{ label: 'Planos e Preços', icon: 'pi pi-list', to: '/saas/plans' },
{ label: 'Vitrine Pública', icon: 'pi pi-megaphone', to: '/saas/plans-public' },
{ label: 'Recursos', icon: 'pi pi-bolt', to: '/saas/features' },
{ label: 'Controle de Recursos', icon: 'pi pi-th-large', to: '/saas/plan-features' },
{ label: 'Limites por Plano', icon: 'pi pi-sliders-h', to: '/saas/plan-limits' }
]
},
{
label: 'Assinaturas',
icon: 'pi pi-credit-card',
path: '/saas/subscriptions',
items: [
{ label: 'Listagem de Assinaturas', icon: 'pi pi-list', to: '/saas/subscriptions' },
{ label: 'Intenções', icon: 'pi pi-inbox', to: '/saas/subscription-intents' },
{ label: 'Histórico', icon: 'pi pi-history', to: '/saas/subscription-events' },
{
label: 'Saúde das Assinaturas',
icon: 'pi pi-shield',
to: '/saas/subscription-health',
...mismatchBadge
}
]
},
{ label: 'Clínicas (Tenants)', icon: 'pi pi-users', to: '/saas/tenants' },
{ label: 'Feriados', icon: 'pi pi-star', to: '/saas/feriados' },
{ label: 'Suporte Técnico', icon: 'pi pi-headphones', to: '/saas/support' },
{
label: 'Conteúdo',
icon: 'pi pi-book',
path: '/saas/content',
...(docsAtencaoCount > 0 ? { badge: String(docsAtencaoCount), badgeClass: 'p-badge p-badge-danger' } : {}),
items: [
{
label: 'Documentação',
icon: 'pi pi-question-circle',
to: '/saas/docs',
...docsBadge
},
{ label: 'FAQ', icon: 'pi pi-comments', to: '/saas/faq' }
]
label: 'Saúde das Assinaturas',
icon: 'pi pi-fw pi-shield',
to: '/saas/subscription-health',
...mismatchBadge
}
]
},
{
label: 'Operações',
items: [
{ label: 'Clínicas (Tenants)', icon: 'pi pi-fw pi-users', to: '/saas/tenants' },
{ label: 'Feriados', icon: 'pi pi-fw pi-star', to: '/saas/feriados' },
{ label: 'Suporte Técnico', icon: 'pi pi-fw pi-headphones', to: '/saas/support' }
]
},
{
label: 'Conteúdo',
items: [
{
label: 'Documentação',
icon: 'pi pi-fw pi-question-circle',
to: '/saas/docs',
...docsBadge
},
{ label: 'FAQ', icon: 'pi pi-fw pi-comments', to: '/saas/faq' }
]
}
]
}
}

View File

@@ -1,27 +1,28 @@
// src/navigation/menus/supervisor.menu.js
export default [
{
label: 'Início',
items: [
{ label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/supervisor' }
]
},
{
label: 'Supervisão',
items: [
// ======================================================
// 📊 DASHBOARD
// ======================================================
{ label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/supervisor' },
// ======================================================
// 🎓 SALA DE SUPERVISÃO
// ======================================================
{
label: 'Sala de Supervisão',
icon: 'pi pi-fw pi-users',
to: '/supervisor/sala',
feature: 'supervisor.access'
},
}
]
},
// ======================================================
// 💳 PLANO / CONTA
// ======================================================
{
label: 'Conta',
items: [
{ label: 'Meu plano', icon: 'pi pi-fw pi-credit-card', to: '/supervisor/meu-plano' },
{ label: 'Meu Perfil', icon: 'pi pi-fw pi-user', to: '/account/profile' },
{ label: 'Segurança', icon: 'pi pi-fw pi-shield', to: '/account/security' }

View File

@@ -2,39 +2,36 @@
export default [
{
label: 'Terapeuta',
label: 'Início',
items: [
// ======================================================
// 📊 DASHBOARD
// ======================================================
{ label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/therapist' },
{ label: 'Dashboard', icon: 'pi pi-fw pi-home', to: '/therapist' }
]
},
// ======================================================
// 📅 AGENDA
// ======================================================
{ label: 'Agenda', icon: 'pi pi-fw pi-calendar', to: '/therapist/agenda', feature: 'agenda.view', proBadge: true },
{
label: 'Agenda',
items: [
{ label: 'Agenda', icon: 'pi pi-fw pi-calendar', to: '/therapist/agenda', feature: 'agenda.view', proBadge: true },
{ label: 'Compromissos', icon: 'pi pi-fw pi-clock', to: '/therapist/agenda/compromissos', feature: 'agenda.view', proBadge: true }
]
},
// ✅ NOVO: Compromissos determinísticos (tipos)
{ label: 'Compromissos', icon: 'pi pi-fw pi-clock', to: '/therapist/agenda/compromissos', feature: 'agenda.view', proBadge: true },
{
label: 'Pacientes',
items: [
{ label: 'Meus pacientes', icon: 'pi pi-list', to: '/therapist/patients' },
{ label: 'Grupo de pacientes', icon: 'pi pi-fw pi-users', to: '/therapist/patients/grupos' },
{ label: 'Tags', icon: 'pi pi-tags', to: '/therapist/patients/tags' },
{ label: 'Meu link de cadastro', icon: 'pi pi-link', to: '/therapist/patients/link-externo' },
{ label: 'Cadastros recebidos', icon: 'pi pi-inbox', to: '/therapist/patients/cadastro/recebidos' }
]
},
// ======================================================
// 👥 PATIENTS
// ======================================================
{ label: 'Meus pacientes', icon: 'pi pi-list', to: '/therapist/patients' },
{ label: 'Grupo de pacientes', icon: 'pi pi-fw pi-users', to: '/therapist/patients/grupos' },
{ label: 'Tags', icon: 'pi pi-tags', to: '/therapist/patients/tags' },
{ label: 'Meu link de cadastro', icon: 'pi pi-link', to: '/therapist/patients/link-externo' },
{ label: 'Cadastros recebidos', icon: 'pi pi-inbox', to: '/therapist/patients/cadastro/recebidos' },
// ======================================================
// 🔒 PRO — Online Scheduling
// ======================================================
{
label: 'Agendamento Online',
items: [
{
label: 'Online Scheduling',
label: 'Configurar página',
icon: 'pi pi-fw pi-globe',
to: '/therapist/online-scheduling',
feature: 'online_scheduling.manage',
@@ -46,19 +43,23 @@ export default [
to: '/therapist/agendamentos-recebidos',
feature: 'online_scheduling.manage',
proBadge: true
},
}
]
},
// ======================================================
// 📈 RELATÓRIOS
// ======================================================
{ label: 'Relatórios', icon: 'pi pi-fw pi-chart-bar', to: '/therapist/relatorios', feature: 'agenda.view' },
{
label: 'Relatórios',
items: [
{ label: 'Relatórios', icon: 'pi pi-fw pi-chart-bar', to: '/therapist/relatorios', feature: 'agenda.view' }
]
},
// ======================================================
// 👤 ACCOUNT
// ======================================================
{
label: 'Conta',
items: [
{ label: 'Meu plano', icon: 'pi pi-fw pi-credit-card', to: '/therapist/meu-plano' },
{ label: 'Meu Perfil', icon: 'pi pi-fw pi-user', to: '/account/profile' },
{ label: 'Segurança', icon: 'pi pi-fw pi-shield', to: '/account/security' }
]
}
]
]

View File

@@ -396,6 +396,32 @@ export function applyGuards (router) {
// ======================================
const isAccountArea = (to.path === '/account' || to.path.startsWith('/account/'))
if (isAccountArea) {
// Garante menu + entitlements ao recarregar diretamente em /account/* (ex.: F5).
// globalRole (profiles.role) não mapeia para menus reais → precisamos da tenant role.
const _menuStore = useMenuStore()
if (!_menuStore.ready) {
try {
const _tStore = useTenantStore()
if (!_tStore.activeRole) {
await _tStore.loadSessionAndTenant()
}
const _role = _tStore.activeRole
const _tid = _tStore.activeTenantId || null
if (_role && _tid) {
// Carrega entitlements do tenant (mesma lógica do guard principal)
const _ent = useEntitlementsStore()
if (shouldLoadEntitlements(_ent, _tid)) {
await loadEntitlementsSafe(_ent, _tid, true)
}
// Entitlements pessoais (therapist/supervisor têm assinatura própria)
const _roleNorm = normalizeRole(_role)
if (['therapist', 'supervisor'].includes(_roleNorm) && _ent.loadedForUser !== uid) {
try { await _ent.loadForUser(uid) } catch {}
}
await ensureMenuBuilt({ uid, tenantId: _tid, tenantRole: _role, globalRole })
}
} catch {}
}
_perfEnd()
return true
}

View File

@@ -574,8 +574,8 @@
</div>
</div>
<!-- Menu Mode -->
<div class="col-span-12 md:col-span-4">
<!-- Menu Mode: relevante no Layout Clássico -->
<div v-if="layoutConfig.variant === 'classic'" class="col-span-12 md:col-span-4">
<div class="prof-ctrl-box">
<div class="prof-ctrl-box__head">
<div>
@@ -1125,7 +1125,7 @@ async function uploadAvatarIfNeeded () {
/* ----------------------------
Aparência (SEM duplicar engine)
----------------------------- */
const { layoutConfig, toggleDarkMode, changeMenuMode } = useLayout()
const { layoutConfig, layoutState, toggleDarkMode, changeMenuMode } = useLayout()
function isDarkNow () {
return document.documentElement.classList.contains('app-dark')
@@ -1155,8 +1155,11 @@ const menuModeModel = computed({
set: (val) => {
if (!val || val === layoutConfig.menuMode) return
layoutConfig.menuMode = val
try { changeMenuMode?.(val) } catch {
try { changeMenuMode?.({ value: val }) } catch {}
// Não chama changeMenuMode() no Rail — ela reseta estados do sidebar
if (layoutConfig.variant !== 'rail') {
try { changeMenuMode?.(val) } catch {
try { changeMenuMode?.({ value: val }) } catch {}
}
}
if (!silentApplying.value) markDirty()
}
@@ -1229,13 +1232,9 @@ async function loadUserSettings (uid) {
// fazendo a sidebar desaparecer ao entrar na página.
}
// layout variant — só aplica se mudou, para não resetar o estado do layout
if (
(settings.layout_variant === 'rail' || settings.layout_variant === 'classic') &&
settings.layout_variant !== layoutConfig.variant
) {
setVariant(settings.layout_variant)
}
// Variant NÃO é re-aplicada aqui: bootstrapUserSettings cuida disso no arranque.
// Re-aplicar no loadUserSettings causava regressão (dado stale do banco sobrescrevia
// o variant ativo). A UI lê layoutConfig.variant diretamente para exibir a seleção.
applyThemeEngine(layoutConfig)
@@ -1405,7 +1404,7 @@ async function saveAll () {
primary_color: layoutConfig.primary || 'noir',
surface_color: layoutConfig.surface || 'slate',
menu_mode: layoutConfig.menuMode || 'static',
layout_variant: layoutConfig.variant || 'classic',
layout_variant: layoutConfig.variant || 'rail',
updated_at: new Date().toISOString()
}
@@ -1425,6 +1424,7 @@ async function saveAll () {
clearAvatarFile()
dirty.value = false
layoutState._variantDirty = false
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Seu perfil foi atualizado.', life: 2500 })
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao salvar', detail: e?.message || 'Não consegui salvar.', life: 6000 })
@@ -1510,9 +1510,6 @@ onBeforeUnmount(() => {
.prof-root {
padding: 1rem;
}
@media (min-width: 768px) {
.prof-root { padding: 1.5rem; }
}
/* ─── Hero ──────────────────────────────────────────────── */
.prof-hero-sentinel { height: 1px; }
@@ -1959,4 +1956,4 @@ onBeforeUnmount(() => {
.prof-slide-leave-to {
opacity: 0; max-height: 0; margin: 0;
}
</style>
</style>

File diff suppressed because it is too large Load Diff