Files
agenciapsilmno/src/layout/AppMenuPopoverContent.vue
T

193 lines
9.1 KiB
Vue

<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Arquivo: src/layout/AppMenuPopoverContent.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, inject, nextTick } from 'vue';
import { useRouter } from 'vue-router';
import { sessionUser } from '@/app/session';
import { useRoleGuard } from '@/composables/useRoleGuard';
import { useLayout } from '@/layout/composables/layout';
import { supabase } from '@/lib/supabase/client';
import { useEntitlementsStore } from '@/stores/entitlementsStore';
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore';
import { useTenantStore } from '@/stores/tenantStore';
const emit = defineEmits(['close']);
const router = useRouter();
const { role, canSee } = useRoleGuard();
const { isDarkTheme, toggleDarkMode } = useLayout();
const queuePatch = inject('queueUserSettingsPatch', null);
const openConfigurator = inject('openConfigurator', null);
// ── Identidade ───────────────────────────────────────────────────────────
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 avatarUrl = computed(() => sessionUser.value?.user_metadata?.avatar_url || null);
// ── Navegação ────────────────────────────────────────────────────────────
async function safePush(target, fallback) {
try {
const r = router.resolve(target);
if (r?.matched?.length) return await router.push(target);
} catch (e) {
console.warn('Erro ao resolver rota:', e);
}
if (fallback) {
try {
return await router.push(fallback);
} catch (e) {
console.warn('Erro ao navegar para fallback:', e);
}
}
return router.push('/');
}
function goMyProfile() {
emit('close');
safePush({ name: 'account-profile' }, '/account/profile');
}
function goSecurity() {
emit('close');
safePush({ name: 'account-security' }, '/account/security');
}
function goSettings() {
emit('close');
if (canSee('settings.view')) return safePush({ name: 'ConfiguracoesAgenda' }, '/admin/settings');
return safePush({ name: 'portal-sessoes' }, '/portal');
}
// ── Dark mode + persistência ─────────────────────────────────────────────
function isDarkNow() {
return document.documentElement.classList.contains('app-dark');
}
async function waitForDarkFlip(before, timeoutMs = 900) {
const start = performance.now();
while (performance.now() - start < timeoutMs) {
await nextTick();
await new Promise((r) => requestAnimationFrame(r));
if (isDarkNow() !== before) return isDarkNow();
}
return isDarkNow();
}
async function toggleDarkAndPersist() {
try {
const before = isDarkNow();
toggleDarkMode();
const after = await waitForDarkFlip(before);
await queuePatch?.({ theme_mode: after ? 'dark' : 'light' }, { flushNow: true });
} catch (e) {
console.error('[AppMenuPopoverContent][theme] falhou:', e?.message || e);
}
}
// ── Configurador ─────────────────────────────────────────────────────────
function handleOpenConfigurator() {
emit('close'); // fecha o popover primeiro
openConfigurator?.(); // abre o footer bar via AppLayout
}
// ── Sign out ─────────────────────────────────────────────────────────────
async function signOut() {
emit('close');
const tenant = useTenantStore();
const ent = useEntitlementsStore();
const tf = useTenantFeaturesStore();
try {
await supabase.auth.signOut();
} finally {
tenant.reset();
ent.invalidate();
tf.invalidate();
sessionStorage.clear();
localStorage.clear();
router.replace('/auth/login');
}
}
</script>
<template>
<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-base 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-base font-bold text-[var(--text-color)] truncate tracking-tight">{{ label }}</span>
<span class="text-[0.8rem] 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-sm 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-sm 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-sm 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>
<!-- Preferências -->
<div class="border-t border-[var(--surface-border)] py-1.5">
<button class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-sm font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150" @click="toggleDarkAndPersist">
<i :class="['pi text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150', isDarkTheme ? 'pi-sun' : 'pi-moon']" />
{{ isDarkTheme ? 'Modo claro' : 'Modo escuro' }}
</button>
<button class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-sm font-medium text-[var(--text-color)] hover:bg-[var(--surface-ground)] hover:pl-4 transition-all duration-150" @click="handleOpenConfigurator">
<i class="pi pi-palette text-[0.72rem] opacity-40 group-hover:opacity-100 group-hover:text-[var(--primary-color)] transition-all duration-150" />
Personalizar tema
</button>
</div>
<!-- Sair -->
<div class="border-t border-[var(--surface-border)] py-1.5">
<button class="group flex items-center gap-2.5 w-full px-3.5 py-[7px] text-sm font-medium text-red-500 hover:bg-red-500/[0.06] hover:pl-4 transition-all duration-150" @click="signOut">
<i class="pi pi-sign-out text-[0.72rem] opacity-60 group-hover:opacity-100 transition-opacity duration-150" />
Sair
</button>
</div>
</div>
</template>