353 lines
19 KiB
Vue
353 lines
19 KiB
Vue
<!--
|
|
|--------------------------------------------------------------------------
|
|
| Agência PSI
|
|
|--------------------------------------------------------------------------
|
|
| Criado e desenvolvido por Leonardo Nohama
|
|
|
|
|
| Tecnologia aplicada à escuta.
|
|
| Estrutura para o cuidado.
|
|
|
|
|
| Arquivo: src/layout/AppMenuFooterPanel.vue
|
|
| Data: 2026
|
|
| Local: São Carlos/SP — Brasil
|
|
|--------------------------------------------------------------------------
|
|
| © 2026 — Todos os direitos reservados
|
|
|--------------------------------------------------------------------------
|
|
-->
|
|
<script setup>
|
|
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue';
|
|
import { useRouter } from 'vue-router';
|
|
|
|
import Popover from 'primevue/popover';
|
|
|
|
import { sessionUser, sessionRole } from '@/app/session';
|
|
import { supabase } from '@/lib/supabase/client';
|
|
import { useRoleGuard } from '@/composables/useRoleGuard';
|
|
import { useLayout } from '@/layout/composables/layout';
|
|
import { useUserSettingsPersistence } from '@/composables/useUserSettingsPersistence';
|
|
import { useConfiguratorBar } from '@/layout/composables/useConfiguratorBar';
|
|
|
|
const props = defineProps({
|
|
variant: { type: String, default: 'sidebar' }
|
|
});
|
|
|
|
const router = useRouter();
|
|
const pop = ref(null);
|
|
|
|
const { role, canSee } = useRoleGuard();
|
|
|
|
const { toggleDarkMode, isDarkTheme } = useLayout();
|
|
const { init: initSettings, queuePatch } = useUserSettingsPersistence();
|
|
const { toggle: toggleThemeBar } = useConfiguratorBar();
|
|
|
|
onMounted(() => initSettings());
|
|
|
|
function isDarkNow() {
|
|
return document.documentElement.classList.contains('app-dark');
|
|
}
|
|
|
|
async function toggleDarkAndPersist() {
|
|
try {
|
|
toggleDarkMode();
|
|
await nextTick();
|
|
const theme_mode = isDarkNow() ? 'dark' : 'light';
|
|
await queuePatch({ theme_mode }, { flushNow: true });
|
|
} catch (e) {
|
|
console.error('[FooterPanel][theme] falhou:', e?.message || e);
|
|
}
|
|
}
|
|
|
|
const initials = computed(() => {
|
|
const name = sessionUser.value?.user_metadata?.full_name || sessionUser.value?.email || '';
|
|
const parts = String(name).trim().split(/\s+/).filter(Boolean);
|
|
const a = parts[0]?.[0] || 'U';
|
|
const b = parts.length > 1 ? parts[parts.length - 1][0] : '';
|
|
return (a + b).toUpperCase();
|
|
});
|
|
|
|
const label = computed(() => {
|
|
const name = sessionUser.value?.user_metadata?.full_name;
|
|
return name || sessionUser.value?.email || 'Conta';
|
|
});
|
|
|
|
const sublabel = computed(() => {
|
|
const r = role.value || sessionRole.value;
|
|
if (!r) return 'Sessão';
|
|
if (r === 'clinic_admin' || r === 'tenant_admin' || r === 'admin') return 'Administrador';
|
|
if (r === 'therapist') return 'Terapeuta';
|
|
if (r === 'portal_user' || r === 'patient') return 'Portal';
|
|
return r;
|
|
});
|
|
|
|
const avatarUrl = computed(() => sessionUser.value?.user_metadata?.avatar_url || null);
|
|
|
|
function toggle(e) {
|
|
pop.value?.toggle(e);
|
|
}
|
|
function close() {
|
|
try {
|
|
pop.value?.hide();
|
|
} catch {}
|
|
}
|
|
|
|
async function safePush(target, fallback) {
|
|
try {
|
|
const r = router.resolve(target);
|
|
if (r?.matched?.length) return await router.push(target);
|
|
} catch {}
|
|
if (fallback) {
|
|
try {
|
|
return await router.push(fallback);
|
|
} catch {}
|
|
}
|
|
return router.push('/');
|
|
}
|
|
|
|
function goMyProfile() {
|
|
close();
|
|
safePush({ name: 'account-profile' }, '/account/profile');
|
|
}
|
|
function goSecurity() {
|
|
close();
|
|
safePush({ name: 'account-security' }, '/account/security');
|
|
}
|
|
function goSettings() {
|
|
close();
|
|
if (canSee('settings.view')) return safePush({ name: 'ConfiguracoesAgenda' }, '/admin/settings');
|
|
return safePush({ name: 'portal-sessoes' }, '/portal');
|
|
}
|
|
function goMyPlan() {
|
|
close();
|
|
const r = role.value || sessionRole.value;
|
|
if (r === 'clinic_admin' || r === 'tenant_admin' || r === 'admin') {
|
|
return safePush({ name: 'admin-meu-plano' }, '/admin/meu-plano');
|
|
}
|
|
if (r === 'supervisor') {
|
|
return safePush({ name: 'supervisor.meu-plano' }, '/supervisor/meu-plano');
|
|
}
|
|
if (r === 'editor') {
|
|
return safePush({ name: 'editor-meu-plano' }, '/editor/meu-plano');
|
|
}
|
|
if (r === 'portal_user' || r === 'patient') {
|
|
return safePush({ name: 'portal-meu-plano' }, '/portal/meu-plano');
|
|
}
|
|
return safePush({ name: 'therapist-meu-plano' }, '/therapist/meu-plano');
|
|
}
|
|
// ── Logout com confirmação ────────────────────────────────────
|
|
const signingOut = ref(false);
|
|
const dots = ref('.');
|
|
let _signOutTimer = null;
|
|
let _dotsTimer = null;
|
|
|
|
function initSignOut() {
|
|
signingOut.value = true;
|
|
dots.value = '.';
|
|
_dotsTimer = setInterval(() => {
|
|
dots.value = dots.value.length >= 3 ? '.' : dots.value + '.';
|
|
}, 400);
|
|
_signOutTimer = setTimeout(async () => {
|
|
_clearSignOutTimers();
|
|
try {
|
|
await supabase.auth.signOut();
|
|
} catch {
|
|
} finally {
|
|
router.push('/auth/login');
|
|
}
|
|
}, 3000);
|
|
}
|
|
|
|
function cancelSignOut() {
|
|
_clearSignOutTimers();
|
|
signingOut.value = false;
|
|
}
|
|
|
|
function _clearSignOutTimers() {
|
|
clearTimeout(_signOutTimer);
|
|
clearInterval(_dotsTimer);
|
|
_signOutTimer = null;
|
|
_dotsTimer = null;
|
|
}
|
|
|
|
onUnmounted(_clearSignOutTimers);
|
|
|
|
defineExpose({ toggle });
|
|
</script>
|
|
|
|
<template>
|
|
<!-- ── SIDEBAR: trigger + popover ── -->
|
|
<template v-if="variant === 'sidebar'">
|
|
<div class="sticky bottom-0 z-20 border-t border-[var(--surface-border)] bg-[var(--surface-card)]">
|
|
<button type="button" class="w-full px-3 py-3 flex items-center gap-3 hover:bg-[var(--surface-ground)] transition-colors duration-150" @click="toggle">
|
|
<img v-if="avatarUrl" :src="avatarUrl" class="h-9 w-9 rounded-xl object-cover border border-[var(--surface-border)]" alt="avatar" />
|
|
<div v-else class="h-9 w-9 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] grid place-items-center text-sm font-semibold">{{ initials }}</div>
|
|
<div class="min-w-0 flex-1 text-left">
|
|
<div class="truncate text-sm font-semibold text-[var(--text-color)]">{{ label }}</div>
|
|
<div class="truncate text-xs text-[var(--text-color-secondary)]">{{ sublabel }}</div>
|
|
</div>
|
|
<i class="pi pi-angle-up text-xs opacity-40" />
|
|
</button>
|
|
|
|
<Popover ref="pop" appendTo="body">
|
|
<!-- conteúdo reutilizado via template inline -->
|
|
<template v-if="true">
|
|
<div class="w-[224px] overflow-hidden rounded-[inherit]">
|
|
<!-- Header -->
|
|
<div class="flex items-center gap-2.5 px-3.5 pt-3.5 pb-3 bg-[var(--primary-color)]/[0.06] border-b border-[var(--surface-border)]">
|
|
<div class="relative shrink-0">
|
|
<img v-if="avatarUrl" :src="avatarUrl" class="w-9 h-9 rounded-[10px] object-cover ring-[1.5px] ring-[var(--primary-color)]/30" alt="avatar" />
|
|
<div v-else class="w-9 h-9 rounded-[10px] grid place-items-center text-[1rem] font-bold text-[var(--primary-color)] bg-[var(--primary-color)]/10 ring-[1.5px] ring-[var(--primary-color)]/20">{{ initials }}</div>
|
|
<span class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-green-500 border-2 border-[var(--surface-card)]" />
|
|
</div>
|
|
<div class="min-w-0 flex flex-col gap-px">
|
|
<span class="text-[1rem] font-bold text-[var(--text-color)] truncate tracking-tight">{{ label }}</span>
|
|
<span class="text-[0.85rem] text-[var(--text-color-secondary)] truncate opacity-70">{{ sessionUser?.email }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Nav items -->
|
|
<div class="py-1.5">
|
|
<button class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150" @click="goMyProfile">
|
|
<i class="pi pi-user text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
|
|
Meu perfil
|
|
</button>
|
|
<button class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150" @click="goMyPlan">
|
|
<i class="pi pi-credit-card text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
|
|
Meu Plano
|
|
</button>
|
|
<button class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150" @click="goSecurity">
|
|
<i class="pi pi-shield text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
|
|
Segurança
|
|
</button>
|
|
<button
|
|
v-if="canSee('settings.view')"
|
|
class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150"
|
|
@click="goSettings"
|
|
>
|
|
<i class="pi pi-cog text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
|
|
Configurações
|
|
</button>
|
|
<div class="my-1 border-t border-[var(--surface-border)]" />
|
|
<button class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150" @click="toggleDarkAndPersist">
|
|
<i :class="['pi', isDarkTheme ? 'pi-sun' : 'pi-moon', 'text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150']" />
|
|
{{ isDarkTheme ? 'Modo claro' : 'Modo escuro' }}
|
|
</button>
|
|
<button class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150" @click="toggleThemeBar">
|
|
<i class="pi pi-palette text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
|
|
Cores do tema
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Footer: Sair -->
|
|
<div class="border-t border-[var(--surface-border)] py-1.5">
|
|
<button v-if="!signingOut" class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-red-500 hover:bg-red-500/[0.06] hover:pl-4 transition-all duration-150" @click="initSignOut">
|
|
<i class="pi pi-sign-out text-[0.72rem] opacity-60 group-hover:opacity-100 transition-opacity duration-150" />
|
|
Sair
|
|
</button>
|
|
<div v-else class="flex items-center gap-2 w-full px-3.5 py-[7px]">
|
|
<span class="flex items-center gap-2.5 flex-1 text-[1rem] font-medium text-red-400 select-none">
|
|
<i class="pi pi-sign-out text-[0.72rem] opacity-60" />
|
|
Saindo{{ dots }}
|
|
</span>
|
|
<button class="shrink-0 text-[0.72rem] text-[var(--text-color-secondary)] hover:text-[var(--text-color)] border border-[var(--surface-border)] rounded px-2 py-0.5 transition-colors duration-150" @click="cancelSignOut">
|
|
Cancelar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</Popover>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- ── RAIL: só o popover, trigger externo ── -->
|
|
<template v-else>
|
|
<Popover ref="pop" appendTo="body">
|
|
<div class="w-[224px] overflow-hidden rounded-[inherit]">
|
|
<!-- Header -->
|
|
<div class="flex items-center gap-2.5 px-3.5 pt-3.5 pb-3 bg-[var(--primary-color)]/[0.06] border-b border-[var(--surface-border)]">
|
|
<div class="relative shrink-0">
|
|
<img v-if="avatarUrl" :src="avatarUrl" class="w-9 h-9 rounded-[10px] object-cover ring-[1.5px] ring-[var(--primary-color)]/30" alt="avatar" />
|
|
<div v-else class="w-9 h-9 rounded-[10px] grid place-items-center text-[1rem] font-bold text-[var(--primary-color)] bg-[var(--primary-color)]/10 ring-[1.5px] ring-[var(--primary-color)]/20">{{ initials }}</div>
|
|
<span class="absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-green-500 border-2 border-[var(--surface-card)]" />
|
|
</div>
|
|
<div class="min-w-0 flex flex-col gap-px">
|
|
<span class="text-[1rem] font-bold text-[var(--text-color)] truncate tracking-tight">{{ label }}</span>
|
|
<span class="text-[0.85rem] text-[var(--text-color-secondary)] truncate opacity-70">{{ sessionUser?.email }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Nav items -->
|
|
<div class="py-1.5">
|
|
<button class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150" @click="goMyProfile">
|
|
<i class="pi pi-user text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
|
|
Meu perfil
|
|
</button>
|
|
<button class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150" @click="goMyPlan">
|
|
<i class="pi pi-credit-card text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
|
|
Meu Plano
|
|
</button>
|
|
<button class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150" @click="goSecurity">
|
|
<i class="pi pi-shield text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
|
|
Segurança
|
|
</button>
|
|
<button
|
|
v-if="canSee('settings.view')"
|
|
class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150"
|
|
@click="goSettings"
|
|
>
|
|
<i class="pi pi-cog text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
|
|
Configurações
|
|
</button>
|
|
<div class="my-1 border-t border-[var(--surface-border)]" />
|
|
<button class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150" @click="toggleDarkAndPersist">
|
|
<i :class="['pi', isDarkTheme ? 'pi-sun' : 'pi-moon', 'text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150']" />
|
|
{{ isDarkTheme ? 'Modo claro' : 'Modo escuro' }}
|
|
</button>
|
|
<div class="relative footer-theme-panel">
|
|
<button
|
|
type="button"
|
|
class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150"
|
|
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 text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
|
|
Cores do tema
|
|
</button>
|
|
<AppConfigurator />
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Footer: Sair -->
|
|
<div class="border-t border-[var(--surface-border)] py-1.5">
|
|
<button v-if="!signingOut" class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-[1rem] font-medium text-red-500 hover:bg-red-500/[0.06] hover:pl-4 transition-all duration-150" @click="initSignOut">
|
|
<i class="pi pi-sign-out text-[0.72rem] opacity-60 group-hover:opacity-100 transition-opacity duration-150" />
|
|
Sair
|
|
</button>
|
|
<div v-else class="flex items-center gap-2 w-full px-3.5 py-[7px]">
|
|
<span class="flex items-center gap-2.5 flex-1 text-[1rem] font-medium text-red-400 select-none">
|
|
<i class="pi pi-sign-out text-[0.72rem] opacity-60" />
|
|
Saindo{{ dots }}
|
|
</span>
|
|
<button class="shrink-0 text-[0.72rem] text-[var(--text-color-secondary)] hover:text-[var(--text-color)] border border-[var(--surface-border)] rounded px-2 py-0.5 transition-colors duration-150" @click="cancelSignOut">
|
|
Cancelar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Popover>
|
|
</template>
|
|
</template>
|
|
|
|
<style>
|
|
/* Zero padding nativo do PrimeVue — mínimo inevitável pois não há prop pra isso */
|
|
.p-popover-content {
|
|
padding: 0 !important;
|
|
}
|
|
</style>
|