Files
agenciapsilmno/src/layout/AppLayout.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>