Files
agenciapsilmno/src/layout/AppTopbar.vue
T
Leonardo 27467bbb68 M1: features/medicos + features/insurance + ComponentCadastroRapido refactor
Modulo 1 da Fase 1 de padronizacao. Novos features/medicos (services
+ composable useMedicos) e features/insurance (idem). 3 cadastros
rapidos (medicos, convenios, ComponentCadastroRapido + Insurance
PlanQuickCreateDialog) migrados pra usar os composables novos —
zero supabase.from() em UI components. TEST_ACCOUNTS extraido pra
src/config/devTestAccounts.js. Topbar ganhou switcher de layout
+ atalhos M1 via novo useTopbarDevMenuExtras. M1.6 MelissaLayout
90 imports deferida pra sessao dedicada (memoria padronizacao_sweep).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:19:57 -03:00

850 lines
26 KiB
Vue

<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/layout/AppTopbar.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { computed, nextTick, onMounted, provide, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useLayout } from '@/layout/composables/layout';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { useEntitlementsStore } from '@/stores/entitlementsStore';
import { useTenantStore } from '@/stores/tenantStore';
import { useRoleGuard } from '@/composables/useRoleGuard';
const { canSee } = useRoleGuard();
import { useAjuda } from '@/composables/useAjuda';
const { openDrawer: openAjudaDrawer, closeDrawer: closeAjudaDrawer, drawerOpen: ajudaDrawerOpen } = useAjuda();
import NotificationDrawer from '@/components/notifications/NotificationDrawer.vue';
import GlobalSearch from '@/components/search/GlobalSearch.vue';
import { useNotifications } from '@/composables/useNotifications';
import { useNotificationStore } from '@/stores/notificationStore';
const notificationStore = useNotificationStore();
useNotifications();
function toggleAjuda() {
ajudaDrawerOpen.value ? closeAjudaDrawer() : openAjudaDrawer();
}
import { useUserSettingsPersistence } from '@/composables/useUserSettingsPersistence';
import { useTopbarDevMenuExtras } from '@/composables/useTopbarDevMenuExtras';
import { applyThemeEngine } from '@/theme/theme.options';
import { fetchAllNotices } from '@/features/notices/noticeService';
import { useTenantFeaturesStore } from '@/stores/tenantFeaturesStore';
const toast = useToast();
const entitlementsStore = useEntitlementsStore();
const tenantStore = useTenantStore();
const { toggleMenu, toggleDarkMode, isDarkTheme, layoutConfig, changeMenuMode, isRailMobile } = useLayout();
const router = useRouter();
const route = useRoute();
const planBtn = ref(null);
/* ----------------------------
Persistência
----------------------------- */
const { init: initUserSettings, queuePatch } = useUserSettingsPersistence();
provide('queueUserSettingsPatch', queuePatch);
/* ----------------------------
Contexto (UID/Email/Tenant)
----------------------------- */
const sessionUid = ref(null);
const sessionEmail = ref(null);
async function loadSessionIdentity() {
try {
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
sessionUid.value = data?.user?.id || null;
sessionEmail.value = data?.user?.email || null;
} catch (e) {
sessionUid.value = null;
sessionEmail.value = null;
console.warn('[Topbar][identity] falhou:', e?.message || e);
}
}
const tenantId = computed(() => tenantStore.activeTenantId || null);
// ✅ Admin Dev
const ctxItems = computed(() => {
const items = [];
// ids (sempre úteis pra debug)
if (tenantId.value) items.push({ k: 'Tenant', v: tenantId.value });
if (sessionUid.value) items.push({ k: 'UID', v: sessionUid.value });
return items;
});
/* ----------------------------
Fonte da verdade: DOM
----------------------------- */
function isDarkNow() {
return document.documentElement.classList.contains('app-dark');
}
function setDarkMode(shouldBeDark) {
const now = isDarkNow();
if (shouldBeDark !== now) toggleDarkMode();
}
async function waitForDarkFlip(before, timeoutMs = 900) {
const start = performance.now();
while (performance.now() - start < timeoutMs) {
await nextTick();
await new Promise((r) => requestAnimationFrame(r));
const now = isDarkNow();
if (now !== before) return now;
}
return isDarkNow();
}
async function loadAndApplyUserSettings() {
try {
const { data: u, error: uErr } = await supabase.auth.getUser();
if (uErr) throw uErr;
const uid = u?.user?.id;
if (!uid) return;
const { data: settings, error } = await supabase.from('user_settings').select('theme_mode, preset, primary_color, surface_color, menu_mode').eq('user_id', uid).maybeSingle();
if (error) throw error;
if (!settings) return;
// 1) dark/light (DOM é a fonte da verdade)
if (settings.theme_mode) setDarkMode(settings.theme_mode === 'dark');
// 2) layoutConfig
if (settings.preset) layoutConfig.preset = settings.preset;
if (settings.primary_color) layoutConfig.primary = settings.primary_color;
if (settings.surface_color) layoutConfig.surface = settings.surface_color;
if (settings.menu_mode) layoutConfig.menuMode = settings.menu_mode;
// 3) aplica engine UMA vez
applyThemeEngine(layoutConfig);
// 4) persiste no localStorage para carregamento instantâneo no próximo boot
try {
localStorage.setItem('ui_theme_config', JSON.stringify({
preset: layoutConfig.preset,
primary: layoutConfig.primary,
surface: layoutConfig.surface,
menuMode: layoutConfig.menuMode
}));
} catch {}
// ✅ IMPORTANTE:
// changeMenuMode NÃO é só "setar menuMode".
// Ele reseta estados do sidebar/overlay/mobile e previne drift.
// No layout Rail, não deve ser chamado — ele não usa menuMode.
if (layoutConfig.variant !== 'rail') {
try {
changeMenuMode(layoutConfig.menuMode);
} catch (e) {
try {
changeMenuMode({ value: layoutConfig.menuMode });
} catch {}
console.warn('[Topbar][bootstrap] changeMenuMode falhou:', e?.message || e);
}
}
} catch (e) {
console.error('[Topbar][bootstrap] erro:', e?.message || e);
}
}
async function toggleDarkAndPersistSilently() {
try {
const before = isDarkNow();
toggleDarkMode();
const after = await waitForDarkFlip(before);
const theme_mode = after ? 'dark' : 'light';
try { localStorage.setItem('ui_theme_mode', theme_mode); } catch {}
await queuePatch({ theme_mode }, { flushNow: true });
} catch (e) {
console.error('[Topbar][theme] falhou:', e?.message || e);
}
}
/* ----------------------------
Plano (DEV) — popup menu
----------------------------- */
const trocandoPlano = ref(false);
const enablePlanToggle = computed(() => {
const flag = String(import.meta.env?.VITE_ENABLE_PLAN_TOGGLE || '').toLowerCase();
return Boolean(import.meta.env?.DEV) || flag === 'true';
});
const showPlanDevMenu = computed(() => {
return canSee('settings.view') && enablePlanToggle.value;
});
const ctxMenu = ref();
const ctxMenuModel = computed(() =>
ctxItems.value.length
? ctxItems.value.map((it) => ({
label: `${it.k}: ${it.v}`,
icon: it.k === 'Tenant' ? 'pi pi-building' : 'pi pi-user'
}))
: [{ label: 'Sem contexto', icon: 'pi pi-info-circle', disabled: true }]
);
function openCtxMenu(event) {
ctxMenu.value?.toggle?.(event);
}
const planMenu = ref();
const planMenuLoading = ref(false);
const planMenuTarget = ref(null); // 'therapist' | 'clinic' | null
const planMenuSub = ref(null); // subscription ativa (obj)
const planMenuPlans = ref([]); // plans ativos do target
async function getMyUserId() {
const { data, error } = await supabase.auth.getUser();
if (error) throw error;
const uid = data?.user?.id;
if (!uid) throw new Error('Sessão inválida (sem user).');
return uid;
}
// therapist subscription: user_id — sem filtro de tenant_id (pode estar preenchido)
async function getActiveTherapistSubscription() {
const uid = await getMyUserId();
const { data, error } = await supabase.from('subscriptions').select('id, tenant_id, user_id, plan_id, status, updated_at').eq('user_id', uid).order('updated_at', { ascending: false }).limit(10);
if (error) throw error;
const list = data || [];
if (!list.length) return null;
const priority = (st) => {
const s = String(st || '').toLowerCase();
if (s === 'active') return 1;
if (s === 'trialing') return 2;
if (s === 'past_due') return 3;
if (s === 'unpaid') return 4;
if (s === 'incomplete') return 5;
if (s === 'canceled' || s === 'cancelled') return 9;
return 8;
};
return list.slice().sort((a, b) => {
const pa = priority(a?.status);
const pb = priority(b?.status);
if (pa !== pb) return pa - pb;
return new Date(b?.updated_at || 0) - new Date(a?.updated_at || 0);
})[0];
}
async function getActiveClinicSubscription() {
const tid = tenantId.value;
if (!tid) return null;
const { data, error } = await supabase.from('subscriptions').select('id, tenant_id, user_id, plan_id, status, updated_at').eq('tenant_id', tid).eq('status', 'active').order('updated_at', { ascending: false }).limit(1).maybeSingle();
if (error) throw error;
return data || null;
}
async function listActivePlansByTarget(target) {
const { data, error } = await supabase.from('plans').select('id, key, target, is_active').eq('target', target).eq('is_active', true).order('key', { ascending: true });
if (error) throw error;
return data || [];
}
async function refreshEntitlementsAfterToggle(target) {
// ✅ aqui NÃO dá pra usar invalidate geral, porque precisamos dos dois caches
// mas durante toggle, é mais seguro forçar recarga do escopo que foi alterado.
if (target === 'clinic') {
const tid = tenantId.value;
if (!tid) return;
await entitlementsStore.loadForTenant(tid, { force: true });
return;
}
// therapist
const uid = await getMyUserId();
await entitlementsStore.loadForUser(uid, { force: true });
}
/**
* ✅ Resolve a subscription ativa levando em conta a área da rota atual.
*
* Áreas de clínica (/admin, /supervisor) → contexto tenant_id (clinic)
* Demais áreas (/therapist, /editor, /portal, etc.) → contexto user_id (pessoal)
*
* Isso evita que um editor que também é membro de uma clínica veja o plano
* da clínica no botão DEV em vez do seu próprio plano.
*/
async function resolveActiveSubscriptionContext() {
const path = route.path || '';
const isClinicContext = path.startsWith('/admin') || path.startsWith('/supervisor');
if (isClinicContext && tenantId.value) {
const clinicSub = await getActiveClinicSubscription();
if (clinicSub) return { sub: clinicSub, target: 'clinic' };
}
const therapistSub = await getActiveTherapistSubscription();
if (therapistSub) return { sub: therapistSub, target: 'therapist' };
// último fallback: clinic (caso não-clínica sem sub pessoal)
if (tenantId.value) {
const clinicSub = await getActiveClinicSubscription();
return { sub: clinicSub || null, target: clinicSub ? 'clinic' : null };
}
return { sub: null, target: null };
}
function normalizeKey(k) {
return String(k || '').trim();
}
// free primeiro, depois o resto por key
function sortPlansSmart(plans) {
const arr = [...(plans || [])];
arr.sort((a, b) => {
const ak = normalizeKey(a?.key).toLowerCase();
const bk = normalizeKey(b?.key).toLowerCase();
const aIsFree = ak.endsWith('_free') || ak === 'free';
const bIsFree = bk.endsWith('_free') || bk === 'free';
if (aIsFree && !bIsFree) return -1;
if (!aIsFree && bIsFree) return 1;
return ak.localeCompare(bk);
});
return arr;
}
async function loadPlanMenuData() {
planMenuLoading.value = true;
try {
const { sub, target } = await resolveActiveSubscriptionContext();
planMenuSub.value = sub;
planMenuTarget.value = target;
if (!sub?.id || !target) {
planMenuPlans.value = [];
return;
}
const plans = await listActivePlansByTarget(target);
planMenuPlans.value = sortPlansSmart(plans);
} finally {
planMenuLoading.value = false;
}
}
const planMenuModel = computed(() => {
const sub = planMenuSub.value;
const target = planMenuTarget.value;
const plans = planMenuPlans.value || [];
if (!sub?.id || !target) {
return [
{ label: 'Sem assinatura ativa', icon: 'pi pi-exclamation-triangle', disabled: true },
{ label: 'Não encontrei subscription ativa nem para therapist (user_id) nem para clinic (tenant_id).', disabled: true }
];
}
const currentPlanId = String(sub.plan_id || '');
const header = {
label: `Planos (${target})`,
icon: target === 'therapist' ? 'pi pi-user' : 'pi pi-building',
disabled: true
};
const subInfo = {
label: `Sub: ${String(sub.id).slice(0, 8)}… • Atual: ${String(currentPlanId).slice(0, 8)}`,
icon: 'pi pi-info-circle',
disabled: true
};
const items = [];
let insertedSeparator = false;
plans.forEach((p) => {
const isCurrent = String(p.id) === currentPlanId;
const keyLower = String(p.key || '').toLowerCase();
const isFree = keyLower.endsWith('_free') || keyLower === 'free';
items.push({
label: isCurrent ? `${p.key} (atual)` : p.key,
icon: isCurrent ? 'pi pi-check' : isFree ? 'pi pi-star' : 'pi pi-circle',
disabled: isCurrent || planMenuLoading.value || trocandoPlano.value,
command: async () => {
await changePlanTo(p.id, p.key, target);
}
});
if (!insertedSeparator && isFree) {
items.push({ separator: true });
insertedSeparator = true;
}
});
if (items.length && items[items.length - 1]?.separator) items.pop();
if (!plans.length) {
return [header, subInfo, { separator: true }, { label: 'Nenhum plano ativo encontrado', icon: 'pi pi-info-circle', disabled: true }];
}
return [header, subInfo, { separator: true }, ...items];
});
// ─── Extras do menu DEV (layout switcher + atalhos M1) ────────────
const { devExtrasModel } = useTopbarDevMenuExtras();
const combinedDevMenuModel = computed(() => [
...planMenuModel.value,
{ separator: true },
...devExtrasModel.value
]);
async function openPlanMenu(event) {
if (!showPlanDevMenu.value) return;
try {
await loadPlanMenuData();
} catch (err) {
console.error('[PLANO][DEV menu] erro:', err?.message || err);
toast.add({
severity: 'error',
summary: 'Erro ao carregar planos',
detail: err?.message || 'Falha desconhecida.',
life: 5200
});
}
const anchorEl = planBtn.value?.$el || event?.currentTarget || event?.target;
if (!anchorEl) {
planMenu.value?.toggle?.(event);
return;
}
planMenu.value?.show?.({ currentTarget: anchorEl });
}
async function changePlanTo(newPlanId, newPlanKey, target) {
if (trocandoPlano.value) return;
trocandoPlano.value = true;
try {
const sub = planMenuSub.value;
if (!sub?.id) throw new Error('Subscription inválida.');
const { error: rpcError } = await supabase.rpc('change_subscription_plan', {
p_subscription_id: sub.id,
p_new_plan_id: newPlanId
});
if (rpcError) throw rpcError;
planMenuSub.value = { ...sub, plan_id: newPlanId };
// ✅ recarrega o escopo certo (tenant ou user)
await refreshEntitlementsAfterToggle(target);
toast.add({
severity: 'success',
summary: 'Plano alterado (DEV)',
detail: `${String(newPlanKey).toUpperCase()} aplicado (${target})`,
life: 3200
});
} catch (err) {
console.error('[PLANO] Erro ao trocar:', err?.message || err);
toast.add({
severity: 'error',
summary: 'Erro ao trocar plano',
detail: err?.message || 'Falha desconhecida.',
life: 6000
});
} finally {
trocandoPlano.value = false;
}
}
/* ----------------------------
SaaS — indicador de avisos ativos
----------------------------- */
const isSaasArea = computed(() => String(route.path || '').startsWith('/saas'));
const saasActiveCount = ref(0);
async function loadSaasNoticeCount() {
try {
const all = await fetchAllNotices();
saasActiveCount.value = all.filter((n) => n.is_active).length;
} catch {
/* silencioso */
}
}
watch(
isSaasArea,
(is) => {
if (is) loadSaasNoticeCount();
},
{ immediate: true }
);
/* ----------------------------
Logout
----------------------------- */
async function logout() {
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');
}
}
/**
* ✅ Bootstrap entitlements (resolve "menu não alterna" sem depender do guard)
* - se tem tenant ativo => carrega tenant entitlements
* - senão => carrega user entitlements
*/
async function bootstrapEntitlements() {
try {
const uid = sessionUid.value || (await getMyUserId());
const tid = tenantId.value;
if (tid) {
await entitlementsStore.loadForTenant(tid, { force: false, maxAgeMs: 60_000 });
} else if (uid) {
await entitlementsStore.loadForUser(uid, { force: false, maxAgeMs: 60_000 });
}
} catch (e) {
console.warn('[Topbar][entitlements bootstrap] falhou:', e?.message || e);
}
}
onMounted(async () => {
await initUserSettings();
await loadAndApplyUserSettings();
await loadSessionIdentity();
await bootstrapEntitlements();
});
</script>
<template>
<header class="rail-topbar">
<!-- Esquerda -->
<div class="rail-topbar__left">
<!-- Hamburguer: aparece em xl (1280px) no Rail -->
<button class="layout-menu-button rail-topbar__btn rail-topbar__hamburger" @click="toggleMenu">
<i class="pi pi-bars"></i>
</button>
<router-link to="/" class="layout-topbar-logo ml-3">
<span>Agência PSI</span>
</router-link>
<!-- Indicador de avisos globais ativos SaaS only -->
<router-link v-if="isSaasArea && saasActiveCount > 0" to="/saas/global-notices" class="topbar-notice-chip ml-3" :title="`${saasActiveCount} aviso(s) global(is) ativo(s)`">
<span class="topbar-notice-chip__dot" />
<i class="pi pi-megaphone topbar-notice-chip__icon" />
<span class="topbar-notice-chip__label"> {{ saasActiveCount }} aviso{{ saasActiveCount !== 1 ? 's' : '' }} ativo{{ saasActiveCount !== 1 ? 's' : '' }} </span>
</router-link>
<!-- Pills: visíveis apenas em > xl (1280px) -->
<div class="topbar-ctx-row ml-2">
<span v-for="(it, idx) in ctxItems" :key="`${it.k}-${idx}`" class="topbar-ctx-pill" :title="`${it.k}: ${it.v}`">
<b class="topbar-ctx-k">{{ it.k }}:</b>
<span class="topbar-ctx-v">{{ it.v }}</span>
</span>
</div>
<!-- Botão Tenant/UID: visível apenas em xl (1280px) -->
<button type="button" class="rail-topbar__btn topbar-ctx-btn ml-2" title="Tenant / UID" @click="openCtxMenu">
<i class="pi pi-id-card" />
<span class="topbar-ctx-btn__label">Tenant / UID</span>
</button>
<Menu ref="ctxMenu" :model="ctxMenuModel" popup appendTo="body" :baseZIndex="3000" />
</div>
<!-- Busca global (Ctrl+K) -->
<div class="rail-topbar__search">
<GlobalSearch />
</div>
<!-- Ações -->
<div class="rail-topbar__actions">
<!-- Plan Dev Button -->
<Button v-if="showPlanDevMenu" ref="planBtn" outlined :loading="planMenuLoading || trocandoPlano" :disabled="planMenuLoading || trocandoPlano" @click="openPlanMenu" class="rail-topbar__btn">
<i class="pi pi-sliders-h" />
</Button>
<Menu ref="planMenu" :model="combinedDevMenuModel" popup appendTo="body" :baseZIndex="3000" />
<!-- Notificações -->
<div class="relative">
<button type="button" class="rail-topbar__btn" title="Notificações" @click="notificationStore.drawerOpen = true">
<i class="pi pi-bell" />
<span v-if="notificationStore.unreadCount > 0" class="rail-topbar__notification-badge">
{{ notificationStore.unreadCount > 99 ? '99+' : notificationStore.unreadCount }}
</span>
</button>
<NotificationDrawer />
</div>
<!-- Ajuda -->
<button type="button" class="rail-topbar__btn" :class="{ 'rail-topbar__btn--active': ajudaDrawerOpen }" :title="ajudaDrawerOpen ? 'Fechar ajuda' : 'Ajuda'" @click="toggleAjuda">
<i class="pi pi-question-circle" />
</button>
<!-- Dark mode -->
<button type="button" class="rail-topbar__btn" :title="isDarkTheme ? 'Modo claro' : 'Modo escuro'" @click="toggleDarkAndPersistSilently">
<i :class="['pi', { 'pi-moon': isDarkTheme, 'pi-sun': !isDarkTheme }]"></i>
</button>
</div>
</header>
</template>
<style scoped>
.rail-topbar {
position: fixed;
top: var(--notice-banner-height, 0px);
left: 0;
right: 0;
height: 56px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1.25rem;
border-bottom: 1px solid var(--surface-border);
background: var(--surface-card);
z-index: 100;
}
.rail-topbar__left {
display: flex;
align-items: center;
min-width: 0;
}
/* Hamburguer: visível apenas em ≤ xl (1280px)
!important necessário para sobrescrever CSS do tema (.layout-menu-button) */
.rail-topbar__hamburger {
display: none !important;
}
@media (width <= theme(--breakpoint-xl, 1280px)) {
.rail-topbar__hamburger {
display: grid !important;
}
}
.rail-topbar__actions {
display: flex;
align-items: center;
gap: 0.25rem;
}
/* Busca global — centralizada entre left e actions */
.rail-topbar__search {
display: flex;
align-items: center;
flex: 1 1 auto;
justify-content: center;
min-width: 0;
padding: 0 1rem;
max-width: 520px;
margin: 0 auto;
}
@media (max-width: 640px) {
.rail-topbar__search {
padding: 0 0.5rem;
}
}
@media (max-width: 480px) {
.rail-topbar__search {
padding: 0 0.25rem;
flex: 0 0 auto;
}
}
.rail-topbar__btn {
width: 2.25rem;
height: 2.25rem;
border-radius: 50%;
border: none;
background: transparent;
color: var(--text-color-secondary);
display: grid;
place-items: center;
cursor: pointer;
font-size: 1rem;
transition:
background 0.15s,
color 0.15s;
}
.rail-topbar__btn:hover {
background: var(--surface-ground);
color: var(--text-color);
}
.rail-topbar__btn--highlight {
color: var(--primary-color);
}
.rail-topbar__btn--active {
background: var(--surface-ground);
color: var(--primary-color);
}
.config-panel {
z-index: 200;
}
/* ── Chip de avisos ativos (SaaS) ────────────────────────── */
.topbar-notice-chip {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.28rem 0.75rem 0.28rem 0.55rem;
border-radius: 999px;
background: #f59e0b;
color: #fff;
font-size: 0.78rem;
font-weight: 700;
text-decoration: none;
white-space: nowrap;
flex-shrink: 0;
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.55);
animation: topbar-notice-pulse 2s ease-in-out infinite;
transition: filter 0.15s;
}
.topbar-notice-chip:hover {
filter: brightness(1.1);
}
.topbar-notice-chip__dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #fff;
flex-shrink: 0;
animation: topbar-dot-blink 1.4s ease-in-out infinite;
}
.topbar-notice-chip__icon {
font-size: 0.8rem;
}
@keyframes topbar-notice-pulse {
0%,
100% {
box-shadow: 0 0 0 0 rgba(245, 158, 11, 0.55);
}
50% {
box-shadow: 0 0 0 6px rgba(245, 158, 11, 0);
}
}
@keyframes topbar-dot-blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.35;
}
}
/* Mobile: oculta o texto, mantém ícone + dot */
@media (max-width: 600px) {
.topbar-notice-chip__label {
display: none;
}
.topbar-notice-chip {
padding: 0.28rem 0.5rem;
}
}
/* Badge de notificações */
.rail-topbar__notification-badge {
position: absolute;
top: 0;
right: 0;
min-width: 1rem;
height: 1rem;
padding: 0 0.25rem;
border-radius: 999px;
background: #ef4444;
color: #fff;
font-size: 0.62rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
pointer-events: none;
transform: translate(25%, -25%);
}
.topbar-ctx-row {
display: flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
}
.topbar-ctx-pill {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.25rem 0.45rem;
border-radius: 999px;
border: 1px solid var(--surface-border);
background: var(--surface-card);
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.02);
}
/* Botão Tenant/UID: só em ≤ xl (1280px) */
.topbar-ctx-btn {
display: none !important;
width: auto !important;
border-radius: 999px !important;
padding: 0 0.65rem !important;
gap: 0.35rem;
font-size: 0.8rem;
border: 1px solid var(--surface-border) !important;
}
.topbar-ctx-btn__label {
font-size: 0.8rem;
color: var(--text-color-secondary);
}
@media (width <= theme(--breakpoint-xl, 1280px)) {
.topbar-ctx-row {
display: none !important;
}
.topbar-ctx-btn {
display: inline-flex !important;
}
}
</style>