Drawer mobile + footer colado + Menu nomeado + tenant ensureLoaded

Tres ajustes globais nas Melissa Pages com sidebar:

1) FOOTER "Limpar filtros" colado no bottom do drawer mobile

   Problema: o sticky bottom precisa que algum container parent
   tenha altura definida e overflow. No drawer, o `.xx-side` tinha
   `height: auto` — entao o footer ficava no fluxo natural (logo
   apos os cards) mesmo com pouco conteudo, em vez de empurrado pro
   bottom do drawer.

   Fix: `.xx-mobile-drawer__scroll .xx-side` ganha
   `flex: 1; min-height: 0; display: flex; flex-direction: column`
   pra ocupar altura disponivel; o `.xx-side__footer` ganha
   `margin: auto -12px -24px` (margin-top: auto empurra pro fim).
   Sticky bottom continua pro caso de scroll com muito conteudo.

   Aplicado em: Compromissos, Grupos, Tags, Medicos, Conversas,
   Recorrencias, Pacientes (caso especial — separa .mp-side de
   .mp-quick), Cadastros Recebidos, FinanceiroLancamentos.

2) DRAWER MOBILE adicionado em Notificacoes, Documentos e
   Relatorios (estavam com sidebar virando topo via max-height
   50vh — faltava o pattern oficial das demais Melissa Pages).

   Pattern aplicado:
   - Aside host com id="<prefix>-mobile-drawer-target" + Transition
     backdrop com fade
   - Botao "Menu <Secao>" no header (esquerda do titulo)
   - <Teleport :disabled="!isMobile"> envolvendo a sidebar
   - Script: drawerOpen + isMobile + matchMedia listener registrado
     no onMounted, removido no onBeforeUnmount
   - CSS completo: .xx-mobile-drawer (fixed, transform translateX),
     __scroll (overflow + padding), __backdrop (rgba 0.45 + blur),
     overrides quando teleportada (sidebar perde bg/border-right,
     footer vira sticky bottom com margin-top auto)

3) Botao "Menu" passa a ter sufixo da pagina:
   - "Menu Lancamentos" (FinanceiroLancamentos)
   - "Menu Notificacoes" (Notificacoes)
   - "Menu Documentos" (Documentos)
   - "Menu Relatorios" (Relatorios)
   - "Menu Agendamentos" (AgendamentosRecebidos — corrigido tambem)

4) Bug de "lista vazia ao carregar via URL direto":

   FinanceiroLancamentos e Relatorios usam composables que dependem
   de tenantStore.activeTenantId. Quando aberta direto via URL
   (sem navegar pelo menu), o tenantStore pode nao estar inicializado
   ainda — entao fetchRecords() / loadSessions() retornam vazio.

   Fix: adicionar `await tenantStore.ensureLoaded()` no onMounted
   antes do fetch. Ja era pattern usado em outras Melissa Pages
   (Compromissos, etc).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-06 10:40:07 -03:00
parent 532204708e
commit 48bf2726a5
13 changed files with 698 additions and 49 deletions
@@ -509,7 +509,7 @@ onBeforeUnmount(() => {
@click="toggleDrawer"
>
<i class="pi pi-bars" />
<span>Menu</span>
<span>Menu Agendamentos</span>
</button>
<div class="mar-page__title">
<i class="pi pi-inbox mar-page__title-icon" />
@@ -1846,11 +1846,14 @@ onBeforeUnmount(() => {
precisa do bg/border-right que tem em desktop. */
.mcr-mobile-drawer__scroll .mcr-side {
width: 100%;
height: auto;
flex: 1;
min-height: 0;
overflow: visible;
padding: 0;
background: transparent;
border-right: none;
display: flex;
flex-direction: column;
}
.mcr-mobile-drawer__scroll .mcr-w--side {
margin: 0 0 12px;
+5 -2
View File
@@ -1862,11 +1862,14 @@ async function onDelete(c) {
do drawer via position: sticky. */
.mc-mobile-drawer__scroll .mc-side {
width: 100%;
height: auto;
flex: 1;
min-height: 0;
overflow: visible;
padding: 0;
background: transparent;
border-right: none;
display: flex;
flex-direction: column;
}
.mc-mobile-drawer__scroll .mc-side__scroll {
flex: none;
@@ -1878,7 +1881,7 @@ async function onDelete(c) {
.mc-mobile-drawer__scroll .mc-side__footer {
position: sticky;
bottom: 0;
margin: 8px -12px -24px; /* compensa o padding do drawer pra ficar de borda a borda */
margin: auto -12px -24px; /* compensa o padding do drawer pra ficar de borda a borda */
background: var(--m-bg-medium);
border-top: 1px solid var(--m-border);
backdrop-filter: blur(24px) saturate(160%);
+5 -2
View File
@@ -1210,11 +1210,14 @@ watch(() => tenantStore.activeTenantId, async () => {
tem padding). Footer vira sticky no bottom do drawer. */
.mw-mobile-drawer__scroll .mw-side {
width: 100%;
height: auto;
flex: 1;
min-height: 0;
overflow: visible;
padding: 0;
background: transparent;
border-right: none;
display: flex;
flex-direction: column;
}
.mw-mobile-drawer__scroll .mw-side__scroll {
flex: none;
@@ -1232,7 +1235,7 @@ watch(() => tenantStore.activeTenantId, async () => {
.mw-mobile-drawer__scroll .mw-side__footer {
position: sticky;
bottom: 0;
margin: 8px -12px -24px;
margin: auto -12px -24px;
padding: 12px;
background: var(--m-bg-medium);
border-top: 1px solid var(--m-border);
+156 -8
View File
@@ -12,7 +12,7 @@
* abrir um paciente específico via /melissa/pacientes (atualmente
* desabilitados aqui). Lista é read-only nesse contexto.
*/
import { ref, computed, onMounted, watch } from 'vue';
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
@@ -28,6 +28,17 @@ const emit = defineEmits(['close']);
const toast = useToast();
const confirm = useConfirm();
// ── Breakpoints + drawer mobile ────────────────────────
const drawerOpen = ref(false);
const isMobile = ref(false);
let _mqMobile = null;
function _onMqMobileChange(e) {
isMobile.value = e.matches;
if (!e.matches) drawerOpen.value = false;
}
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
function fecharDrawer() { drawerOpen.value = false; }
// patientId = null → modo "todos os pacientes" (read-only).
const patientId = computed(() => null);
@@ -110,15 +121,48 @@ function onSign(doc) {
watch(filters, () => fetchDocuments(), { deep: true });
onMounted(async () => {
if (typeof window !== 'undefined' && window.matchMedia) {
_mqMobile = window.matchMedia('(max-width: 1023px)');
isMobile.value = _mqMobile.matches;
_mqMobile.addEventListener('change', _onMqMobileChange);
}
await Promise.all([fetchDocuments(), fetchUsedTags()]);
});
onBeforeUnmount(() => {
if (_mqMobile) _mqMobile.removeEventListener('change', _onMqMobileChange);
});
</script>
<template>
<ConfirmDialog />
<!-- Drawer host (mobile) -->
<aside
class="md-mobile-drawer"
:class="{ 'is-open': drawerOpen }"
v-show="isMobile"
aria-label="Estatísticas e filtros"
>
<div id="md-mobile-drawer-target" class="md-mobile-drawer__scroll" />
</aside>
<Transition name="md-drawer-fade">
<div
v-if="isMobile && drawerOpen"
class="md-mobile-drawer__backdrop"
@click="fecharDrawer"
/>
</Transition>
<section class="md-page">
<header class="md-page__head">
<button
class="md-menu-btn md-menu-btn--mobile-only"
v-tooltip.bottom="'Estatísticas & filtros'"
@click="toggleDrawer"
>
<i class="pi pi-bars" />
<span>Menu Documentos</span>
</button>
<div class="md-page__title">
<i class="pi pi-file md-page__title-icon" />
<span>Documentos</span>
@@ -151,6 +195,7 @@ onMounted(async () => {
<div class="md-body">
<!-- COL 1: Stats + filtros -->
<Teleport to="#md-mobile-drawer-target" :disabled="!isMobile">
<aside class="md-side">
<div class="md-side__scroll">
<!-- Stats -->
@@ -236,6 +281,7 @@ onMounted(async () => {
</div>
</Transition>
</aside>
</Teleport>
<!-- COL 2: Lista -->
<div class="md-main">
@@ -688,16 +734,118 @@ onMounted(async () => {
padding: 12px;
}
/* ─── Botão Menu mobile (abre drawer com sidebar) ─── */
.md-menu-btn {
display: none;
height: 32px;
align-items: center;
gap: 6px;
flex-shrink: 0;
background: var(--m-accent);
border: 1px solid var(--m-accent);
color: white;
padding: 0 11px;
border-radius: 9px;
cursor: pointer;
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
transition: background-color 140ms ease, transform 140ms ease;
}
.md-menu-btn:hover {
background: color-mix(in srgb, var(--m-accent) 88%, white);
transform: translateY(-1px);
}
.md-menu-btn > i { font-size: 0.85rem; }
/* ─── Drawer mobile ─── */
.md-mobile-drawer {
position: fixed;
top: 0; left: 0;
height: 100vh;
height: 100dvh;
width: min(360px, 88vw);
z-index: 80;
background: var(--m-bg-medium);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border-right: 1px solid var(--m-border);
transform: translateX(-100%);
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
color: var(--m-text);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
}
.md-mobile-drawer.is-open { transform: translateX(0); }
.md-mobile-drawer__scroll {
height: 100%;
overflow-y: auto;
overflow-x: hidden;
padding: 12px 12px 24px;
display: flex;
flex-direction: column;
gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.md-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
.md-mobile-drawer__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.md-mobile-drawer__scroll .md-side {
width: 100%;
flex: 1;
min-height: 0;
overflow: visible;
padding: 0;
background: transparent;
border-right: none;
display: flex;
flex-direction: column;
}
.md-mobile-drawer__scroll .md-side__scroll {
flex: none;
min-height: 0;
overflow: visible;
display: flex;
flex-direction: column;
gap: 12px;
}
.md-mobile-drawer__scroll .md-w--side {
margin: 0;
}
.md-mobile-drawer__scroll .md-w--side:last-of-type { margin-bottom: 0; }
.md-mobile-drawer__scroll .md-side__footer {
position: sticky;
bottom: 0;
margin: auto -12px -24px;
padding: 12px;
background: var(--m-bg-medium);
border-top: 1px solid var(--m-border);
backdrop-filter: blur(24px) saturate(160%);
-webkit-backdrop-filter: blur(24px) saturate(160%);
z-index: 5;
}
.md-mobile-drawer__backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 79;
}
.md-drawer-fade-enter-active,
.md-drawer-fade-leave-active { transition: opacity 200ms ease; }
.md-drawer-fade-enter-from,
.md-drawer-fade-leave-to { opacity: 0; }
/* ─── Mobile (<1024px) ─── */
@media (max-width: 1023px) {
.md-body { flex-direction: column; padding: 0; }
.md-side {
width: 100%;
max-height: 50vh;
border-right: none;
border-bottom: 1px solid var(--m-border);
}
.md-main { padding: 8px; }
.md-main { width: 100%; padding: 8px; }
.md-page__title > span:first-of-type { display: none; }
.md-menu-btn--mobile-only { display: inline-flex; }
}
</style>
@@ -12,7 +12,7 @@
* RPCs + dialogs registrar pagamento e lançamento manual). Só o chrome
* muda pra eliminar o triplo-header.
*/
import { ref, computed, watch, onMounted } from 'vue';
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { supabase } from '@/lib/supabase/client';
@@ -27,6 +27,17 @@ const toast = useToast();
const confirm = useConfirm();
const tenantStore = useTenantStore();
// ── Breakpoints + drawer mobile (mesmo pattern das demais Melissa Pages) ──
const drawerOpen = ref(false);
const isMobile = ref(false);
let _mqMobile = null;
function _onMqMobileChange(e) {
isMobile.value = e.matches;
if (!e.matches) drawerOpen.value = false;
}
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
function fecharDrawer() { drawerOpen.value = false; }
// ── Helpers de formatação ─────────────────────────────
const _brl = new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' });
function fmtBRL(v) { return _brl.format(v ?? 0); }
@@ -258,15 +269,54 @@ async function saveManualRecord() {
// ── Lifecycle ─────────────────────────────────────────
onMounted(async () => {
if (typeof window !== 'undefined' && window.matchMedia) {
_mqMobile = window.matchMedia('(max-width: 1023px)');
isMobile.value = _mqMobile.matches;
_mqMobile.addEventListener('change', _onMqMobileChange);
}
// Garante tenant carregado antes de fetch — sem isso, o useFinancialRecords
// retorna vazio na primeira render direta via URL (bug: lista vazia até
// navegar pelo menu e remontar o componente).
if (typeof tenantStore.ensureLoaded === 'function') {
await tenantStore.ensureLoaded();
}
await Promise.all([loadPatients(), applyFilters()]);
});
onBeforeUnmount(() => {
if (_mqMobile) _mqMobile.removeEventListener('change', _onMqMobileChange);
});
</script>
<template>
<ConfirmDialog />
<!-- Drawer host (mobile) sidebar é teleportada pra quando isMobile -->
<aside
class="mfl-mobile-drawer"
:class="{ 'is-open': drawerOpen }"
v-show="isMobile"
aria-label="Estatísticas e filtros"
>
<div id="mfl-mobile-drawer-target" class="mfl-mobile-drawer__scroll" />
</aside>
<Transition name="mfl-drawer-fade">
<div
v-if="isMobile && drawerOpen"
class="mfl-mobile-drawer__backdrop"
@click="fecharDrawer"
/>
</Transition>
<section class="mfl-page">
<header class="mfl-page__head">
<button
class="mfl-menu-btn mfl-menu-btn--mobile-only"
v-tooltip.bottom="'Estatísticas & filtros'"
@click="toggleDrawer"
>
<i class="pi pi-bars" />
<span>Menu Lançamentos</span>
</button>
<div class="mfl-page__title">
<i class="pi pi-list mfl-page__title-icon" />
<span>Lançamentos financeiros</span>
@@ -308,6 +358,7 @@ onMounted(async () => {
<div class="mfl-body">
<!-- COL 1: Stats + filtros -->
<Teleport to="#mfl-mobile-drawer-target" :disabled="!isMobile">
<aside class="mfl-side">
<div class="mfl-side__scroll">
<!-- Stats -->
@@ -461,6 +512,7 @@ onMounted(async () => {
</div>
</Transition>
</aside>
</Teleport>
<!-- COL 2: DataTable -->
<div class="mfl-main">
@@ -1507,17 +1559,124 @@ onMounted(async () => {
color: white !important;
}
/* ─── Botão "Menu" mobile (abre drawer com sidebar) ─── */
.mfl-menu-btn {
display: none;
height: 32px;
align-items: center;
gap: 6px;
flex-shrink: 0;
background: var(--m-accent);
border: 1px solid var(--m-accent);
color: white;
padding: 0 11px;
border-radius: 9px;
cursor: pointer;
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
transition: background-color 140ms ease, transform 140ms ease;
}
.mfl-menu-btn:hover {
background: color-mix(in srgb, var(--m-accent) 88%, white);
transform: translateY(-1px);
}
.mfl-menu-btn > i { font-size: 0.85rem; }
/* ─── Drawer mobile (Teleport target) ─── */
.mfl-mobile-drawer {
position: fixed;
top: 0;
left: 0;
height: 100vh;
height: 100dvh;
width: min(360px, 88vw);
z-index: 80;
background: var(--m-bg-medium);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border-right: 1px solid var(--m-border);
transform: translateX(-100%);
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
color: var(--m-text);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
}
.mfl-mobile-drawer.is-open { transform: translateX(0); }
.mfl-mobile-drawer__scroll {
height: 100%;
overflow-y: auto;
overflow-x: hidden;
padding: 12px 12px 24px;
display: flex;
flex-direction: column;
gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mfl-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
.mfl-mobile-drawer__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
/* Sidebar teleportada perde bg/border-right (drawer já tem chrome próprio).
Cards perdem margem lateral (drawer já tem padding). Footer vira sticky
no bottom do drawer pra "Limpar filtros" sempre visível. */
.mfl-mobile-drawer__scroll .mfl-side {
width: 100%;
flex: 1;
min-height: 0;
overflow: visible;
padding: 0;
background: transparent;
border-right: none;
display: flex;
flex-direction: column;
}
.mfl-mobile-drawer__scroll .mfl-side__scroll {
flex: none;
min-height: 0;
overflow: visible;
display: flex;
flex-direction: column;
gap: 12px;
}
.mfl-mobile-drawer__scroll .mfl-w--side {
margin: 0;
}
.mfl-mobile-drawer__scroll .mfl-w--side:last-of-type { margin-bottom: 0; }
.mfl-mobile-drawer__scroll .mfl-side__footer {
position: sticky;
bottom: 0;
margin: auto -12px -24px;
padding: 12px;
background: var(--m-bg-medium);
border-top: 1px solid var(--m-border);
backdrop-filter: blur(24px) saturate(160%);
-webkit-backdrop-filter: blur(24px) saturate(160%);
z-index: 5;
}
.mfl-mobile-drawer__backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 79;
}
.mfl-drawer-fade-enter-active,
.mfl-drawer-fade-leave-active { transition: opacity 200ms ease; }
.mfl-drawer-fade-enter-from,
.mfl-drawer-fade-leave-to { opacity: 0; }
/* ─── Mobile (<1024px) ─── */
@media (max-width: 1023px) {
/* Sidebar saiu pro drawer via Teleport — body fica só com .mfl-main. */
.mfl-body { flex-direction: column; padding: 0; }
.mfl-side {
width: 100%;
max-height: 50vh;
border-right: none;
border-bottom: 1px solid var(--m-border);
}
.mfl-main { padding: 8px; }
.mfl-main { width: 100%; padding: 8px; }
.mfl-page__title > span:first-of-type { display: none; }
.mfl-menu-btn--mobile-only { display: inline-flex; }
.mfl-act-btn--primary span { display: none; }
.mfl-act-btn--primary { width: 32px; padding: 0; justify-content: center; }
}
+5 -2
View File
@@ -2311,11 +2311,14 @@ watch(editPatientDialog, (isOpen) => {
}
.mg-mobile-drawer__scroll .mg-side {
width: 100%;
height: auto;
flex: 1;
min-height: 0;
overflow: visible;
padding: 0;
background: transparent;
border-right: none;
display: flex;
flex-direction: column;
}
.mg-mobile-drawer__scroll .mg-side__scroll {
flex: none;
@@ -2327,7 +2330,7 @@ watch(editPatientDialog, (isOpen) => {
.mg-mobile-drawer__scroll .mg-side__footer {
position: sticky;
bottom: 0;
margin: 8px -12px -24px;
margin: auto -12px -24px;
background: var(--m-bg-medium);
border-top: 1px solid var(--m-border);
backdrop-filter: blur(24px) saturate(160%);
+5 -2
View File
@@ -2488,11 +2488,14 @@ watch(editPatientDialog, (isOpen) => {
}
.mm-mobile-drawer__scroll .mm-side {
width: 100%;
height: auto;
flex: 1;
min-height: 0;
overflow: visible;
padding: 0;
background: transparent;
border-right: none;
display: flex;
flex-direction: column;
}
.mm-mobile-drawer__scroll .mm-side__scroll {
flex: none;
@@ -2504,7 +2507,7 @@ watch(editPatientDialog, (isOpen) => {
.mm-mobile-drawer__scroll .mm-side__footer {
position: sticky;
bottom: 0;
margin: 8px -12px -24px;
margin: auto -12px -24px;
background: var(--m-bg-medium);
border-top: 1px solid var(--m-border);
backdrop-filter: blur(24px) saturate(160%);
+160 -8
View File
@@ -14,7 +14,7 @@
* - outras com deeplink → router.push(deeplink)
* - todas marcam como lida automaticamente se ainda não estiverem.
*/
import { ref, computed, onMounted, watch } from 'vue';
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
@@ -35,6 +35,17 @@ const notifStore = useNotificationStore();
const conversationDrawer = useConversationDrawerStore();
const tenantStore = useTenantStore();
// ── Breakpoints + drawer mobile ────────────────────────
const drawerOpen = ref(false);
const isMobile = ref(false);
let _mqMobile = null;
function _onMqMobileChange(e) {
isMobile.value = e.matches;
if (!e.matches) drawerOpen.value = false;
}
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
function fecharDrawer() { drawerOpen.value = false; }
// ── Estado ─────────────────────────────────────────────
const ownerId = ref(null);
const items = ref([]);
@@ -306,15 +317,48 @@ function initials(item) {
watch(() => statusFilter.value, () => { /* noop — filter applies via computed */ });
onMounted(() => {
if (typeof window !== 'undefined' && window.matchMedia) {
_mqMobile = window.matchMedia('(max-width: 1023px)');
isMobile.value = _mqMobile.matches;
_mqMobile.addEventListener('change', _onMqMobileChange);
}
load();
});
onBeforeUnmount(() => {
if (_mqMobile) _mqMobile.removeEventListener('change', _onMqMobileChange);
});
</script>
<template>
<ConfirmDialog />
<!-- Drawer host (mobile) -->
<aside
class="mn-mobile-drawer"
:class="{ 'is-open': drawerOpen }"
v-show="isMobile"
aria-label="Estatísticas e filtros"
>
<div id="mn-mobile-drawer-target" class="mn-mobile-drawer__scroll" />
</aside>
<Transition name="mn-drawer-fade">
<div
v-if="isMobile && drawerOpen"
class="mn-mobile-drawer__backdrop"
@click="fecharDrawer"
/>
</Transition>
<section class="mn-page">
<header class="mn-page__head">
<button
class="mn-menu-btn mn-menu-btn--mobile-only"
v-tooltip.bottom="'Estatísticas & filtros'"
@click="toggleDrawer"
>
<i class="pi pi-bars" />
<span>Menu Notificações</span>
</button>
<div class="mn-page__title">
<i class="pi pi-bell mn-page__title-icon" />
<span>Notificações</span>
@@ -361,6 +405,7 @@ onMounted(() => {
<div class="mn-body">
<!-- COL 1: Stats + filtros -->
<Teleport to="#mn-mobile-drawer-target" :disabled="!isMobile">
<aside class="mn-side">
<div class="mn-side__scroll">
<!-- Stats -->
@@ -448,6 +493,7 @@ onMounted(() => {
</div>
</Transition>
</aside>
</Teleport>
<!-- COL 2: Toolbar + Lista -->
<div class="mn-main">
@@ -1294,17 +1340,123 @@ onMounted(() => {
}
.mn-row__btn > i { font-size: 0.78rem; }
/* ─── Botão Menu mobile (abre drawer com sidebar) ─── */
.mn-menu-btn {
display: none;
height: 32px;
align-items: center;
gap: 6px;
flex-shrink: 0;
background: var(--m-accent);
border: 1px solid var(--m-accent);
color: white;
padding: 0 11px;
border-radius: 9px;
cursor: pointer;
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
transition: background-color 140ms ease, transform 140ms ease;
}
.mn-menu-btn:hover {
background: color-mix(in srgb, var(--m-accent) 88%, white);
transform: translateY(-1px);
}
.mn-menu-btn > i { font-size: 0.85rem; }
/* ─── Drawer mobile (Teleport target) ─── */
.mn-mobile-drawer {
position: fixed;
top: 0;
left: 0;
height: 100vh;
height: 100dvh;
width: min(360px, 88vw);
z-index: 80;
background: var(--m-bg-medium);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border-right: 1px solid var(--m-border);
transform: translateX(-100%);
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
color: var(--m-text);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
}
.mn-mobile-drawer.is-open { transform: translateX(0); }
.mn-mobile-drawer__scroll {
height: 100%;
overflow-y: auto;
overflow-x: hidden;
padding: 12px 12px 24px;
display: flex;
flex-direction: column;
gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mn-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
.mn-mobile-drawer__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
/* Sidebar teleportada — ocupa altura disponivel pra empurrar o footer
pro bottom (margin: auto faz o trabalho). */
.mn-mobile-drawer__scroll .mn-side {
width: 100%;
flex: 1;
min-height: 0;
overflow: visible;
padding: 0;
background: transparent;
border-right: none;
display: flex;
flex-direction: column;
}
.mn-mobile-drawer__scroll .mn-side__scroll {
flex: none;
min-height: 0;
overflow: visible;
display: flex;
flex-direction: column;
gap: 12px;
}
.mn-mobile-drawer__scroll .mn-w--side {
margin: 0;
}
.mn-mobile-drawer__scroll .mn-w--side:last-of-type { margin-bottom: 0; }
.mn-mobile-drawer__scroll .mn-side__footer {
position: sticky;
bottom: 0;
margin: auto -12px -24px;
padding: 12px;
background: var(--m-bg-medium);
border-top: 1px solid var(--m-border);
backdrop-filter: blur(24px) saturate(160%);
-webkit-backdrop-filter: blur(24px) saturate(160%);
z-index: 5;
}
.mn-mobile-drawer__backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 79;
}
.mn-drawer-fade-enter-active,
.mn-drawer-fade-leave-active { transition: opacity 200ms ease; }
.mn-drawer-fade-enter-from,
.mn-drawer-fade-leave-to { opacity: 0; }
/* ─── Mobile (<1024px) ─── */
@media (max-width: 1023px) {
/* Sidebar saiu pro drawer via Teleport — body fica só com .mn-main. */
.mn-body { flex-direction: column; padding: 0; }
.mn-side {
width: 100%;
max-height: 50vh;
border-right: none;
border-bottom: 1px solid var(--m-border);
}
.mn-main { padding: 8px; }
.mn-main { width: 100%; padding: 8px; }
.mn-page__title > span:first-of-type { display: none; }
.mn-menu-btn--mobile-only { display: inline-flex; }
.mn-act-btn span { display: none; }
.mn-act-btn { width: 32px; padding: 0; justify-content: center; }
.mn-row__actions { opacity: 1; }
+16 -2
View File
@@ -2935,7 +2935,6 @@ function sessaoStatusColor(s) {
.mp-side__scroll também perde scroll/padding (drawer já tem) e o
.mp-side__footer vira sticky no bottom do drawer pra manter o
"Limpar filtros" sempre acessível. */
.mp-mobile-drawer__scroll .mp-side,
.mp-mobile-drawer__scroll .mp-quick {
width: 100%;
flex-shrink: 0;
@@ -2946,6 +2945,21 @@ function sessaoStatusColor(s) {
background: transparent;
padding: 0;
}
/* .mp-side ganha flex: 1 pra ocupar altura disponível, com flex column
interno — assim o footer sticky é empurrado pro fim do drawer pelo
margin: auto, mesmo quando há pouco conteúdo. */
.mp-mobile-drawer__scroll .mp-side {
width: 100%;
flex: 1;
min-height: 0;
overflow: visible;
border-right: none;
border-left: none;
background: transparent;
padding: 0;
display: flex;
flex-direction: column;
}
.mp-mobile-drawer__scroll .mp-side__scroll {
flex: none;
min-height: 0;
@@ -2955,7 +2969,7 @@ function sessaoStatusColor(s) {
.mp-mobile-drawer__scroll .mp-side__footer {
position: sticky;
bottom: 0;
margin: 8px -12px -24px; /* compensa o padding do drawer pra ficar de borda a borda */
margin: auto -12px -24px; /* margin-top: auto empurra o footer pro bottom + margin lateral compensa o padding do drawer */
background: var(--m-bg-medium);
border-top: 1px solid var(--m-border);
backdrop-filter: blur(24px) saturate(160%);
+5 -2
View File
@@ -1389,11 +1389,14 @@ onBeforeUnmount(() => {
}
.mr-mobile-drawer__scroll .mr-side {
width: 100%;
height: auto;
flex: 1;
min-height: 0;
overflow: visible;
padding: 0;
background: transparent;
border-right: none;
display: flex;
flex-direction: column;
}
.mr-mobile-drawer__scroll .mr-side__scroll {
flex: none;
@@ -1410,7 +1413,7 @@ onBeforeUnmount(() => {
.mr-mobile-drawer__scroll .mr-side__footer {
position: sticky;
bottom: 0;
margin: 8px -12px -24px;
margin: auto -12px -24px;
padding: 12px;
background: var(--m-bg-medium);
border-top: 1px solid var(--m-border);
+164 -9
View File
@@ -11,7 +11,7 @@
* Lógica idêntica à RelatoriosPage (query agenda_eventos + grouping
* isoWeek/isoMonth + Chart.js).
*/
import { ref, computed, watch, onMounted } from 'vue';
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
// Chart/DataTable/Column/Tag/Skeleton: auto via PrimeVueResolver
@@ -19,6 +19,17 @@ import { useTenantStore } from '@/stores/tenantStore';
const emit = defineEmits(['close']);
const tenantStore = useTenantStore();
// Breakpoints + drawer mobile
const drawerOpen = ref(false);
const isMobile = ref(false);
let _mqMobile = null;
function _onMqMobileChange(e) {
isMobile.value = e.matches;
if (!e.matches) drawerOpen.value = false;
}
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
function fecharDrawer() { drawerOpen.value = false; }
// Período
const PERIOD_OPTIONS = [
{ key: 'week', label: 'Esta semana', icon: 'pi pi-calendar' },
@@ -245,12 +256,52 @@ watch(selectedPeriod, () => {
loadSessions();
});
onMounted(loadSessions);
onMounted(async () => {
if (typeof window !== 'undefined' && window.matchMedia) {
_mqMobile = window.matchMedia('(max-width: 1023px)');
isMobile.value = _mqMobile.matches;
_mqMobile.addEventListener('change', _onMqMobileChange);
}
// Garante tenant carregado antes de carregar sessoes sem isso, a
// primeira render via URL direta pode pegar tenantStore vazio.
if (typeof tenantStore.ensureLoaded === 'function') {
await tenantStore.ensureLoaded();
}
await loadSessions();
});
onBeforeUnmount(() => {
if (_mqMobile) _mqMobile.removeEventListener('change', _onMqMobileChange);
});
</script>
<template>
<!-- Drawer host (mobile) -->
<aside
class="mr-mobile-drawer"
:class="{ 'is-open': drawerOpen }"
v-show="isMobile"
aria-label="Estatísticas e filtros"
>
<div id="mr-mobile-drawer-target" class="mr-mobile-drawer__scroll" />
</aside>
<Transition name="mr-drawer-fade">
<div
v-if="isMobile && drawerOpen"
class="mr-mobile-drawer__backdrop"
@click="fecharDrawer"
/>
</Transition>
<section class="mr-page">
<header class="mr-page__head">
<button
class="mr-menu-btn mr-menu-btn--mobile-only"
v-tooltip.bottom="'Estatísticas & filtros'"
@click="toggleDrawer"
>
<i class="pi pi-bars" />
<span>Menu Relatórios</span>
</button>
<div class="mr-page__title">
<i class="pi pi-chart-bar mr-page__title-icon" />
<span>Relatórios</span>
@@ -283,6 +334,7 @@ onMounted(loadSessions);
<div class="mr-body">
<!-- COL 1: Stats + filtros -->
<Teleport to="#mr-mobile-drawer-target" :disabled="!isMobile">
<aside class="mr-side">
<div class="mr-side__scroll">
<!-- Stats -->
@@ -367,6 +419,7 @@ onMounted(loadSessions);
</div>
</Transition>
</aside>
</Teleport>
<!-- COL 2: Gráfico + Tabela -->
<div class="mr-main">
@@ -998,17 +1051,119 @@ onMounted(loadSessions);
gap: 6px;
}
/* ─── Botão Menu mobile ─── */
.mr-menu-btn {
display: none;
height: 32px;
align-items: center;
gap: 6px;
flex-shrink: 0;
background: var(--m-accent);
border: 1px solid var(--m-accent);
color: white;
padding: 0 11px;
border-radius: 9px;
cursor: pointer;
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
transition: background-color 140ms ease, transform 140ms ease;
}
.mr-menu-btn:hover {
background: color-mix(in srgb, var(--m-accent) 88%, white);
transform: translateY(-1px);
}
.mr-menu-btn > i { font-size: 0.85rem; }
/* ─── Drawer mobile ─── */
.mr-mobile-drawer {
position: fixed;
top: 0; left: 0;
height: 100vh;
height: 100dvh;
width: min(360px, 88vw);
z-index: 80;
background: var(--m-bg-medium);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border-right: 1px solid var(--m-border);
transform: translateX(-100%);
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
color: var(--m-text);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
}
.mr-mobile-drawer.is-open { transform: translateX(0); }
.mr-mobile-drawer__scroll {
height: 100%;
overflow-y: auto;
overflow-x: hidden;
padding: 12px 12px 24px;
display: flex;
flex-direction: column;
gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mr-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
.mr-mobile-drawer__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.mr-mobile-drawer__scroll .mr-side {
width: 100%;
flex: 1;
min-height: 0;
overflow: visible;
padding: 0;
background: transparent;
border-right: none;
display: flex;
flex-direction: column;
}
.mr-mobile-drawer__scroll .mr-side__scroll {
flex: none;
min-height: 0;
overflow: visible;
display: flex;
flex-direction: column;
gap: 12px;
}
.mr-mobile-drawer__scroll .mr-w--side {
margin: 0;
}
.mr-mobile-drawer__scroll .mr-w--side:last-of-type { margin-bottom: 0; }
.mr-mobile-drawer__scroll .mr-side__footer {
position: sticky;
bottom: 0;
margin: auto -12px -24px;
padding: 12px;
background: var(--m-bg-medium);
border-top: 1px solid var(--m-border);
backdrop-filter: blur(24px) saturate(160%);
-webkit-backdrop-filter: blur(24px) saturate(160%);
z-index: 5;
}
.mr-mobile-drawer__backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 79;
}
.mr-drawer-fade-enter-active,
.mr-drawer-fade-leave-active { transition: opacity 200ms ease; }
.mr-drawer-fade-enter-from,
.mr-drawer-fade-leave-to { opacity: 0; }
/* Mobile */
@media (max-width: 1023px) {
.mr-body { flex-direction: column; padding: 0; }
.mr-side {
width: 100%;
max-height: 50vh;
border-right: none;
border-bottom: 1px solid var(--m-border);
}
.mr-main { padding: 8px; }
.mr-main { width: 100%; padding: 8px; }
.mr-page__title > span:first-of-type { display: none; }
.mr-menu-btn--mobile-only { display: inline-flex; }
.mr-stats { grid-template-columns: repeat(3, 1fr); }
}
</style>
+5 -2
View File
@@ -2273,11 +2273,14 @@ watch(editPatientDialog, (isOpen) => {
}
.mt-mobile-drawer__scroll .mt-side {
width: 100%;
height: auto;
flex: 1;
min-height: 0;
overflow: visible;
padding: 0;
background: transparent;
border-right: none;
display: flex;
flex-direction: column;
}
.mt-mobile-drawer__scroll .mt-side__scroll {
flex: none;
@@ -2289,7 +2292,7 @@ watch(editPatientDialog, (isOpen) => {
.mt-mobile-drawer__scroll .mt-side__footer {
position: sticky;
bottom: 0;
margin: 8px -12px -24px;
margin: auto -12px -24px;
background: var(--m-bg-medium);
border-top: 1px solid var(--m-border);
backdrop-filter: blur(24px) saturate(160%);