Files
agenciapsilmno/src/layout/AppLayout.vue
T
Leonardo f646efe522 Toast SLA: botão "Abrir conversa" abre drawer direto da thread
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>
2026-04-23 11:33:38 -03:00

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>