f646efe522
O alerta já vem com payload.thread_key vindo do edge conversation-sla- check. Agora o toast renderiza 2 botões lado a lado quando thread_key existe: - "Abrir conversa" (outlined) → abre ConversationDrawer global direto na thread, sem navegar de página. Usa o store global que já existe. - "Abrir CRM →" (solid) → fallback pra lista inteira via deeplink alias. openConversationDrawer busca o row da view conversation_threads pelo tenant+thread_key e delega pro conversationDrawerStore.openForThread. Se a thread sumiu (arquivada/paciente deletado), cai no fallback de navegar pra /conversas. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
412 lines
14 KiB
Vue
412 lines
14 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, useRouter } from 'vue-router';
|
|
|
|
import { supabase } from '@/lib/supabase/client';
|
|
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
|
|
|
|
const router = useRouter();
|
|
|
|
// Aliases "semânticos" → resolvidos pra rota real com base no role atual.
|
|
// Evita que deeplinks gravados no backend quebrem se o caminho da rota mudar
|
|
// ou se o usuário logado pertencer a outro contexto (therapist vs clinic_admin).
|
|
const DEEPLINK_ALIASES = {
|
|
'/crm/conversas': { therapist: '/therapist/conversas', clinic_admin: '/admin/conversas' },
|
|
'/conversas': { therapist: '/therapist/conversas', clinic_admin: '/admin/conversas' }
|
|
};
|
|
|
|
function resolveDeeplink(link) {
|
|
if (!link || typeof link !== 'string') return link;
|
|
const alias = DEEPLINK_ALIASES[link];
|
|
if (!alias) return link;
|
|
const role = tenantStore?.activeRole || 'therapist';
|
|
return alias[role] || alias.therapist;
|
|
}
|
|
|
|
function goToDeeplink(link) {
|
|
if (!link) return;
|
|
const resolved = resolveDeeplink(link);
|
|
if (typeof resolved === 'string' && resolved.startsWith('/')) {
|
|
router.push(resolved);
|
|
} else {
|
|
window.location.href = resolved;
|
|
}
|
|
}
|
|
|
|
// Abre o drawer global de conversa a partir do thread_key (do payload do alerta).
|
|
// Busca o row da view conversation_threads e delega pro store.
|
|
async function openConversationDrawer(threadKey) {
|
|
if (!threadKey) return;
|
|
try {
|
|
const tenantId = tenantStore.activeTenantId;
|
|
const { data, error } = await supabase
|
|
.from('conversation_threads')
|
|
.select('*')
|
|
.eq('tenant_id', tenantId)
|
|
.eq('thread_key', threadKey)
|
|
.maybeSingle();
|
|
if (error) throw error;
|
|
if (!data) {
|
|
// Thread sumiu (foi arquivada ou paciente deletado) — fallback pra lista
|
|
goToDeeplink('/conversas');
|
|
return;
|
|
}
|
|
const drawerStore = useConversationDrawerStore();
|
|
await drawerStore.openForThread(data);
|
|
} catch (e) {
|
|
console.warn('[AppLayout] openConversationDrawer falhou:', e?.message);
|
|
goToDeeplink('/conversas');
|
|
}
|
|
}
|
|
|
|
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 ConversationDrawer from '@/components/conversations/ConversationDrawer.vue';
|
|
import GlobalInboundNotifier from '@/components/conversations/GlobalInboundNotifier.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 />
|
|
<ConversationDrawer />
|
|
<GlobalInboundNotifier />
|
|
|
|
<!-- Toast especial pra alertas de sistema (heartbeat, saldo baixo, etc) —
|
|
vermelho sticky com botão de ação opcional via payload.deeplink -->
|
|
<Toast group="system-alerts" position="top-right" :pt="{ root: { style: 'width: 28rem; max-width: 92vw' } }">
|
|
<template #message="slotProps">
|
|
<div class="flex flex-col gap-2 w-full">
|
|
<div class="flex items-start gap-2">
|
|
<i class="pi pi-exclamation-circle text-lg mt-0.5" style="color: var(--p-red-500)" />
|
|
<div class="flex-1 min-w-0">
|
|
<div class="font-bold text-sm">{{ slotProps.message.summary }}</div>
|
|
<div class="text-xs mt-0.5 leading-relaxed opacity-85">{{ slotProps.message.detail }}</div>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-2 self-end flex-wrap">
|
|
<Button
|
|
v-if="slotProps.message.data?.thread_key"
|
|
label="Abrir conversa"
|
|
icon="pi pi-comment"
|
|
size="small"
|
|
severity="danger"
|
|
outlined
|
|
class="rounded-full"
|
|
@click="openConversationDrawer(slotProps.message.data.thread_key)"
|
|
/>
|
|
<Button
|
|
v-if="slotProps.message.data?.deeplink"
|
|
:label="slotProps.message.data?.actionLabel || 'Abrir'"
|
|
icon="pi pi-arrow-right"
|
|
iconPos="right"
|
|
size="small"
|
|
severity="danger"
|
|
class="rounded-full"
|
|
@click="goToDeeplink(slotProps.message.data.deeplink)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</Toast>
|
|
</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>
|