313 lines
10 KiB
Vue
313 lines
10 KiB
Vue
<!--
|
|
|--------------------------------------------------------------------------
|
|
| Agência PSI
|
|
|--------------------------------------------------------------------------
|
|
| Criado e desenvolvido por Leonardo Nohama
|
|
|
|
|
| Tecnologia aplicada à escuta.
|
|
| Estrutura para o cuidado.
|
|
|
|
|
| Arquivo: src/layout/AppLayout.vue
|
|
| Data: 2026
|
|
| Local: São Carlos/SP — Brasil
|
|
|--------------------------------------------------------------------------
|
|
| © 2026 — Todos os direitos reservados
|
|
|--------------------------------------------------------------------------
|
|
-->
|
|
<script setup>
|
|
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 AppRailSidebar from './AppRailSidebar.vue';
|
|
import AjudaDrawer from '@/components/AjudaDrawer.vue';
|
|
import SupportDebugBanner from '@/support/components/SupportDebugBanner.vue';
|
|
import GlobalNoticeBanner from '@/features/notices/GlobalNoticeBanner.vue';
|
|
import AppThemeBar from './AppThemeBar.vue';
|
|
import { useConfiguratorBar } from './composables/useConfiguratorBar';
|
|
|
|
const { open: themeBarOpen } = useConfiguratorBar();
|
|
|
|
import { useNoticeStore } from '@/stores/noticeStore';
|
|
import { useTenantStore } from '@/stores/tenantStore';
|
|
import { useEntitlementsStore } from '@/stores/entitlementsStore';
|
|
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore';
|
|
|
|
import { fetchDocsForPath, useAjuda } from '@/composables/useAjuda';
|
|
|
|
const { drawerOpen } = useAjuda();
|
|
|
|
const ajudaPushStyle = computed(() => ({
|
|
transition: 'padding-right 0.3s ease',
|
|
paddingRight: drawerOpen.value ? '420px' : '0'
|
|
}));
|
|
|
|
const route = useRoute();
|
|
const noticeStore = useNoticeStore();
|
|
const { layoutConfig, layoutState, hideMobileMenu, isDesktop, effectiveVariant, effectiveMenuMode } = useLayout();
|
|
|
|
|
|
const layoutArea = computed(() => route.meta?.area || null);
|
|
provide('layoutArea', layoutArea);
|
|
|
|
const tenantStore = useTenantStore();
|
|
const entitlementsStore = useEntitlementsStore();
|
|
const tf = useTenantFeaturesStore();
|
|
|
|
// ── Atualiza contexto dos notices ao mudar de rota ────────────
|
|
watch(
|
|
() => route.path,
|
|
(path) => noticeStore.updateContext(path, tenantStore.role),
|
|
{ immediate: false }
|
|
);
|
|
|
|
const containerClass = computed(() => {
|
|
return {
|
|
'layout-overlay': effectiveMenuMode.value === 'overlay',
|
|
'layout-static': effectiveMenuMode.value === '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() {
|
|
const p = String(route.path || '');
|
|
const isTenantArea = p.startsWith('/admin') || p.startsWith('/therapist') || p.startsWith('/supervisor') || p.startsWith('/saas');
|
|
if (!isTenantArea) return;
|
|
revalidateAfterSessionRefresh();
|
|
}
|
|
|
|
watch(
|
|
() => route.path,
|
|
(path) => fetchDocsForPath(path),
|
|
{ immediate: true }
|
|
);
|
|
|
|
onMounted(() => {
|
|
window.addEventListener('app:session-refreshed', onSessionRefreshed);
|
|
noticeStore.init(tenantStore.role, route.path);
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
window.removeEventListener('app:session-refreshed', onSessionRefreshed);
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<!-- ══ Fullscreen ══ -->
|
|
<template v-if="route.meta?.fullscreen">
|
|
<router-view />
|
|
<Toast />
|
|
</template>
|
|
|
|
<!-- ══ Layout Rail ══ -->
|
|
<template v-else-if="effectiveVariant === 'rail'">
|
|
<div class="l2-root">
|
|
<!-- Rail de ícones: oculto em mobile (≤ xl (1280px)) via CSS -->
|
|
<AppRail />
|
|
<div class="l2-body">
|
|
<AppTopbar />
|
|
<div class="l2-content">
|
|
<AppRailPanel />
|
|
<div class="l2-main" :style="ajudaPushStyle">
|
|
<router-view />
|
|
</div>
|
|
</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 Clássico melhorado ══ -->
|
|
<template v-else>
|
|
<div class="layout-wrapper" :class="containerClass">
|
|
<AppTopbar />
|
|
<AppSidebar />
|
|
<div class="layout-main-container" :style="ajudaPushStyle">
|
|
<div class="layout-main">
|
|
<router-view />
|
|
</div>
|
|
<AppFooter />
|
|
</div>
|
|
<div class="layout-mask animate-fadein" @click="hideMobileMenu" />
|
|
</div>
|
|
<AjudaDrawer />
|
|
<Toast />
|
|
</template>
|
|
|
|
<!-- ══ Barra de tema — persiste em qualquer layout/rota ══ -->
|
|
<Transition name="theme-bar">
|
|
<AppThemeBar v-if="themeBarOpen" />
|
|
</Transition>
|
|
|
|
<!-- ══ Global — fora de todos os branches, persiste em qualquer layout/rota ══ -->
|
|
<SupportDebugBanner />
|
|
<GlobalNoticeBanner />
|
|
</template>
|
|
|
|
<style>
|
|
/* ──────────────────────────────────────────────
|
|
LAYOUT CLÁSSICO — ajustes globais (não scoped)
|
|
para sobrescrever o tema PrimeVue
|
|
────────────────────────────────────────────── */
|
|
|
|
/* ── Global Notice Banner: variável de altura ─────────────
|
|
Definida aqui como fallback; o componente altera via JS */
|
|
:root {
|
|
--notice-banner-height: 0px;
|
|
}
|
|
|
|
/* ── Topbar: desce pelo banner ─────────────────────────── */
|
|
.rail-topbar {
|
|
top: var(--notice-banner-height) !important;
|
|
}
|
|
|
|
/* ── Sidebar — sempre abaixo da topbar fixed (56px) ────────
|
|
Desce pelo banner também. */
|
|
.layout-sidebar {
|
|
position: fixed !important;
|
|
top: calc(56px + var(--notice-banner-height)) !important;
|
|
left: 0 !important;
|
|
height: calc(100vh - 56px - var(--notice-banner-height)) !important;
|
|
border-radius: 0 !important;
|
|
padding: 0 !important;
|
|
box-shadow: 2px 0 6px rgba(0, 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: calc(56px + var(--notice-banner-height)) !important;
|
|
}
|
|
.layout-overlay .layout-main-container,
|
|
.layout-static-inactive .layout-main-container {
|
|
margin-left: 0 !important;
|
|
}
|
|
@media (width <= theme(--breakpoint-xl, 1280px)) {
|
|
.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;
|
|
}
|
|
|
|
/* ── AppThemeBar — slide-up ao abrir, slide-down ao fechar ───
|
|
A transição está definida no próprio componente (.theme-bar).
|
|
Aqui apenas declaramos o estado inicial (enter) / final (leave). */
|
|
.theme-bar-enter-from,
|
|
.theme-bar-leave-to {
|
|
transform: translateY(100%);
|
|
}
|
|
</style>
|
|
|
|
<style scoped>
|
|
/* ─── Layout Rail ─────────────────────────────── */
|
|
.l2-root {
|
|
position: fixed;
|
|
top: var(--notice-banner-height, 0px); /* desce pelo banner */
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
display: flex;
|
|
overflow: hidden;
|
|
background: var(--surface-ground);
|
|
}
|
|
|
|
.l2-body {
|
|
flex: 1;
|
|
min-width: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
height: 100%;
|
|
overflow: hidden;
|
|
padding-top: 56px; /* compensa topbar — banner já absorvido pelo l2-root */
|
|
}
|
|
|
|
.l2-content {
|
|
flex: 1;
|
|
min-height: 0;
|
|
display: flex;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.l2-main {
|
|
flex: 1;
|
|
min-width: 0;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
--layout-sticky-top: 0px;
|
|
}
|
|
|
|
/* Rail de ícones: oculto em mobile */
|
|
@media (width <= theme(--breakpoint-xl, 1280px)) {
|
|
.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>
|