Files
agenciapsilmno/src/layout/melissa/MelissaRelatorios.vue
T
Leonardo d8ce33f74f MelissaRelatorios: tabela primeiro, chart depois
Inverte a ordem dentro do .mr-charts-row via flex order — "Sessoes no
periodo" (tabela) em 1o lugar, "Sessoes por mes/semana" (chart) em 2o.
Vale pra desktop (esquerda/direita) e mobile (em cima/embaixo).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 13:58:17 -03:00

1217 lines
42 KiB
Vue

<script setup>
/*
* MelissaRelatorios — Página nativa Melissa pra relatórios de sessões
* (substitui o embed). Aplica blueprint melissa-table-page-blueprint.md.
*
* Layout 2-col:
* - COL 1 — Sidebar: stats clicáveis + filtros (Período / Status na
* tabela) + footer fixo "Limpar filtros"
* - COL 2 — Main: gráfico + DataTable de sessões filtradas
*
* Lógica idêntica à RelatoriosPage (query agenda_eventos + grouping
* isoWeek/isoMonth + Chart.js).
*/
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
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' },
{ key: 'month', label: 'Este mês', icon: 'pi pi-calendar' },
{ key: '3months', label: 'Últimos 3 meses', icon: 'pi pi-calendar-clock' },
{ key: '6months', label: 'Últimos 6 meses', icon: 'pi pi-calendar-clock' }
];
const selectedPeriod = ref('month');
function periodRange(period) {
const now = new Date();
let start, end;
if (period === 'week') {
start = new Date(now);
start.setDate(now.getDate() - now.getDay());
start.setHours(0, 0, 0, 0);
end = new Date(now);
end.setHours(23, 59, 59, 999);
} else if (period === 'month') {
start = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0);
end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999);
} else if (period === '3months') {
start = new Date(now.getFullYear(), now.getMonth() - 2, 1, 0, 0, 0, 0);
end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999);
} else if (period === '6months') {
start = new Date(now.getFullYear(), now.getMonth() - 5, 1, 0, 0, 0, 0);
end = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59, 999);
}
return { start, end };
}
const periodLabel = computed(() =>
PERIOD_OPTIONS.find((p) => p.key === selectedPeriod.value)?.label ?? ''
);
// ── Dados ──────────────────────────────────────────────
const loading = ref(false);
const hasLoaded = ref(false);
const sessions = ref([]);
const loadError = ref('');
async function loadSessions() {
const uid = tenantStore.user?.id || null;
const tenantId = tenantStore.activeTenantId || null;
if (!uid || !tenantId) return;
const { start, end } = periodRange(selectedPeriod.value);
loading.value = true;
loadError.value = '';
sessions.value = [];
try {
const { data, error } = await supabase
.from('agenda_eventos')
.select('id, inicio_em, fim_em, status, modalidade, tipo, titulo, titulo_custom, patient_id, patients(nome_completo)')
.eq('tenant_id', tenantId)
.eq('owner_id', uid)
.gte('inicio_em', start.toISOString())
.lte('inicio_em', end.toISOString())
.order('inicio_em', { ascending: false })
.limit(500);
if (error) throw error;
sessions.value = data || [];
} catch (e) {
loadError.value = e?.message || 'Falha ao carregar relatório.';
} finally {
loading.value = false;
hasLoaded.value = true;
}
}
// ── Métricas agregadas ────────────────────────────────
const total = computed(() => sessions.value.length);
const realizadas = computed(() => sessions.value.filter((s) => s.status === 'realizado').length);
const faltas = computed(() => sessions.value.filter((s) => s.status === 'faltou').length);
const canceladas = computed(() => sessions.value.filter((s) => s.status === 'cancelado').length);
const agendadas = computed(() => sessions.value.filter((s) => !s.status || s.status === 'agendado').length);
const taxaRealizacao = computed(() => {
const denom = realizadas.value + faltas.value + canceladas.value;
if (!denom) return null;
return Math.round((realizadas.value / denom) * 100);
});
// ── Filtro de status na tabela ────────────────────────
const statusFilter = ref(null); // null | 'realizado' | 'faltou' | 'cancelado' | 'agendado'
const STATUS_FILTER_OPTIONS = [
{ key: 'realizado', label: 'Realizadas', icon: 'pi pi-check-circle' },
{ key: 'faltou', label: 'Faltas', icon: 'pi pi-times-circle' },
{ key: 'cancelado', label: 'Canceladas', icon: 'pi pi-ban' },
{ key: 'agendado', label: 'Agendadas', icon: 'pi pi-calendar' }
];
const sessionsFiltered = computed(() => {
if (!statusFilter.value) return sessions.value;
if (statusFilter.value === 'agendado') {
return sessions.value.filter((s) => !s.status || s.status === 'agendado');
}
return sessions.value.filter((s) => s.status === statusFilter.value);
});
function setStatusFilter(s) {
statusFilter.value = statusFilter.value === s ? null : s;
}
const hasActiveFilters = computed(() =>
selectedPeriod.value !== 'month' || statusFilter.value !== null
);
function clearAllFilters() {
statusFilter.value = null;
if (selectedPeriod.value !== 'month') {
selectedPeriod.value = 'month';
}
}
// ── Stats com classes pra cores ───────────────────────
const stats = computed(() => [
{ key: 'total', label: 'Total', value: total.value, cls: 'neutral' },
{ key: 'realizado', label: 'Realizadas', value: realizadas.value, cls: realizadas.value > 0 ? 'ok' : 'neutral' },
{ key: 'faltou', label: 'Faltas', value: faltas.value, cls: faltas.value > 0 ? 'danger' : 'neutral' },
{ key: 'cancelado', label: 'Canceladas', value: canceladas.value, cls: canceladas.value > 0 ? 'warn' : 'neutral' },
{ key: 'agendado', label: 'Agendadas', value: agendadas.value, cls: agendadas.value > 0 ? 'info' : 'neutral' },
{ key: 'taxa', label: 'Taxa realização', value: taxaRealizacao.value != null ? `${taxaRealizacao.value}%` : '—', cls: taxaRealizacao.value != null && taxaRealizacao.value >= 85 ? 'ok' : 'neutral' }
]);
// ── Gráfico ────────────────────────────────────────────
function isoWeek(d) {
const dt = new Date(d);
const day = dt.getDay() || 7;
dt.setDate(dt.getDate() + 4 - day);
const yearStart = new Date(dt.getFullYear(), 0, 1);
const wk = Math.ceil(((dt - yearStart) / 86400000 + 1) / 7);
return `${dt.getFullYear()}-S${String(wk).padStart(2, '0')}`;
}
function isoMonth(d) {
const dt = new Date(d);
return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, '0')}`;
}
function monthLabel(key) {
const [y, m] = key.split('-');
const names = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez'];
return `${names[Number(m) - 1]}/${y}`;
}
const chartData = computed(() => {
const groupBy = selectedPeriod.value === 'week' ? isoWeek : isoMonth;
const labelFn = selectedPeriod.value === 'week' ? (k) => k : monthLabel;
const buckets = {};
for (const s of sessions.value) {
const key = groupBy(s.inicio_em);
if (!buckets[key]) buckets[key] = { realizado: 0, faltou: 0, cancelado: 0, outros: 0 };
const st = s.status || 'agendado';
if (st === 'realizado') buckets[key].realizado++;
else if (st === 'faltou') buckets[key].faltou++;
else if (st === 'cancelado') buckets[key].cancelado++;
else buckets[key].outros++;
}
const keys = Object.keys(buckets).sort();
return {
labels: keys.map(labelFn),
datasets: [
{ label: 'Realizadas', backgroundColor: 'rgb(22, 163, 74)', data: keys.map((k) => buckets[k].realizado), barThickness: 20 },
{ label: 'Faltas', backgroundColor: 'rgb(220, 38, 38)', data: keys.map((k) => buckets[k].faltou), barThickness: 20 },
{ label: 'Canceladas', backgroundColor: 'rgb(217, 119, 6)', data: keys.map((k) => buckets[k].cancelado), barThickness: 20 },
{ label: 'Outros', backgroundColor: 'rgb(2, 132, 199)', data: keys.map((k) => buckets[k].outros), barThickness: 20 }
]
};
});
const chartOptions = computed(() => {
const ds = getComputedStyle(document.documentElement);
const borderColor = ds.getPropertyValue('--p-content-border-color').trim() || '#e2e8f0';
const textMutedColor = ds.getPropertyValue('--p-text-muted-color').trim() || '#64748b';
return {
maintainAspectRatio: false,
plugins: { legend: { labels: { color: textMutedColor } } },
scales: {
x: { stacked: true, ticks: { color: textMutedColor }, grid: { color: 'transparent' } },
y: { stacked: true, ticks: { color: textMutedColor, precision: 0 }, grid: { color: borderColor, drawTicks: false } }
}
};
});
// ── Tabela helpers ─────────────────────────────────────
const STATUS_LABEL = {
agendado: 'Agendado',
realizado: 'Realizado',
faltou: 'Faltou',
cancelado: 'Cancelado',
remarcado: 'Remarcado',
bloqueado: 'Bloqueado'
};
const STATUS_SEVERITY = {
agendado: 'info',
realizado: 'success',
faltou: 'danger',
cancelado: 'warn',
remarcado: 'secondary',
bloqueado: 'secondary'
};
function fmtDateTimeBR(iso) {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const dd = String(d.getDate()).padStart(2, '0');
const mm = String(d.getMonth() + 1).padStart(2, '0');
const yy = d.getFullYear();
const hh = String(d.getHours()).padStart(2, '0');
const mi = String(d.getMinutes()).padStart(2, '0');
return `${dd}/${mm}/${yy} ${hh}:${mi}`;
}
function sessionTitle(s) {
return s.titulo_custom || s.titulo || (s.tipo ? s.tipo : 'Sessão');
}
function patientName(s) {
return s.patients?.nome_completo || '—';
}
watch(selectedPeriod, () => {
statusFilter.value = null;
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>
<span class="mr-page__count">{{ periodLabel }}</span>
</div>
<div class="mr-page__actions">
<button
class="mr-head-btn"
v-tooltip.bottom="'Recarregar'"
:disabled="loading"
@click="loadSessions"
>
<i :class="loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'" />
</button>
<button class="mr-close" v-tooltip.bottom="'Voltar (Esc)'" @click="emit('close')">
<i class="pi pi-times" />
</button>
</div>
</header>
<!-- Subheader -->
<div class="mr-subheader">
<i class="pi pi-info-circle mr-subheader__icon" />
<span class="mr-subheader__text">
Visão geral de <strong>sessões</strong> no período selecionado.
Mude o período na sidebar à esquerda; os <strong>stats</strong>
e o <strong>gráfico</strong> se adaptam automaticamente.
</span>
</div>
<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 -->
<div class="mr-w mr-w--side">
<div class="mr-w__head">
<span class="mr-w__title"><i class="pi pi-chart-bar" /> Estatísticas</span>
</div>
<div class="mr-stats">
<div
v-for="s in stats"
:key="s.key"
class="mr-stat"
:class="`is-${s.cls}`"
>
<div class="mr-stat__val">{{ s.value }}</div>
<div class="mr-stat__lbl">{{ s.label }}</div>
</div>
</div>
</div>
<!-- Filtro Período -->
<div class="mr-w mr-w--side">
<div class="mr-w__head">
<span class="mr-w__title"><i class="pi pi-calendar" /> Período</span>
<button
v-if="selectedPeriod !== 'month'"
class="mr-side__clear-inline"
v-tooltip.top="'Voltar pro padrão (Este mês)'"
@click="selectedPeriod = 'month'"
>
<i class="pi pi-times" />
</button>
</div>
<div class="mr-side__list">
<button
v-for="o in PERIOD_OPTIONS"
:key="o.key"
class="mr-side__item"
:class="{ 'is-active is-period': selectedPeriod === o.key }"
@click="selectedPeriod = o.key"
>
<i :class="o.icon" />
<span>{{ o.label }}</span>
</button>
</div>
</div>
<!-- Filtro Status (na tabela) -->
<div class="mr-w mr-w--side">
<div class="mr-w__head">
<span class="mr-w__title"><i class="pi pi-filter" /> Status</span>
<button
v-if="statusFilter"
class="mr-side__clear-inline"
v-tooltip.top="'Limpar filtro de status'"
@click="statusFilter = null"
>
<i class="pi pi-times" />
</button>
</div>
<div class="mr-side__list">
<button
v-for="o in STATUS_FILTER_OPTIONS"
:key="o.key"
class="mr-side__item"
:class="[`is-status-${o.key}`, { 'is-active': statusFilter === o.key }]"
@click="setStatusFilter(o.key)"
>
<i :class="o.icon" />
<span>{{ o.label }}</span>
</button>
</div>
</div>
</div>
<Transition name="mr-clear">
<div v-if="hasActiveFilters" class="mr-side__footer">
<button class="mr-side__clear-all" @click="clearAllFilters">
<i class="pi pi-filter-slash" />
<span>Limpar filtros</span>
</button>
</div>
</Transition>
</aside>
</Teleport>
<!-- COL 2: Gráfico + Tabela -->
<div class="mr-main">
<!-- Erro -->
<div v-if="loadError" class="mr-error">
<i class="pi pi-exclamation-triangle" />
<span>{{ loadError }}</span>
</div>
<div class="mr-charts-row">
<!-- Gráfico -->
<div class="mr-card mr-card--chart" style="order: 2;">
<div class="mr-card__head">
<div class="mr-card__icon">
<i class="pi pi-chart-bar" />
</div>
<div class="mr-card__title">
<div class="mr-card__title-text">
Sessões por {{ selectedPeriod === 'week' ? 'semana' : 'mês' }}
</div>
<div class="mr-card__sub">{{ total }} sessão{{ total !== 1 ? 'ões' : '' }} no período</div>
</div>
</div>
<div class="mr-card__body">
<div v-if="loading" class="mr-chart-skel"><Skeleton height="100%" /></div>
<div v-else-if="total === 0" class="mr-empty-inline">
<i class="pi pi-info-circle" /> Sem sessões no período selecionado.
</div>
<div v-else class="mr-chart-wrap">
<Chart type="bar" :data="chartData" :options="chartOptions" style="height: 100%" />
</div>
</div>
</div>
<!-- Tabela -->
<div class="mr-card mr-card--table" style="order: 1;">
<div class="mr-card__head">
<div class="mr-card__icon">
<i class="pi pi-table" />
</div>
<div class="mr-card__title">
<div class="mr-card__title-text">Sessões no período</div>
<div class="mr-card__sub">
<template v-if="statusFilter">
Filtradas por <strong>{{ STATUS_LABEL[statusFilter] || statusFilter }}</strong>
</template>
<template v-else>
Lista completa
</template>
</div>
</div>
<span class="mr-card__count">{{ sessionsFiltered.length }}</span>
</div>
<div v-if="loading" class="mr-card__body">
<Skeleton v-for="n in 5" :key="`sk-${n}`" height="2.5rem" border-radius="6px" class="mb-2" />
</div>
<div v-else-if="!sessions.length" class="mr-empty">
<i class="pi pi-chart-bar mr-empty__icon" />
<div class="mr-empty__title">Nenhuma sessão no período</div>
<div class="mr-empty__hint">Tente selecionar um período diferente na sidebar.</div>
</div>
<div v-else-if="!sessionsFiltered.length" class="mr-empty">
<i class="pi pi-filter-slash mr-empty__icon" />
<div class="mr-empty__title">Nenhuma sessão com este status</div>
<button class="mr-act-btn mr-empty__btn" @click="statusFilter = null">
<i class="pi pi-times" />
<span>Limpar filtro</span>
</button>
</div>
<DataTable
v-else
:value="sessionsFiltered"
:rows="20"
paginator
:rows-per-page-options="[10, 20, 50]"
scrollable
scrollHeight="flex"
class="mr-table"
>
<Column field="inicio_em" header="Data / Hora" :sortable="true" style="min-width: 140px">
<template #body="{ data }">
<span class="font-medium">{{ fmtDateTimeBR(data.inicio_em) }}</span>
</template>
</Column>
<Column header="Paciente" style="min-width: 160px">
<template #body="{ data }">{{ patientName(data) }}</template>
</Column>
<Column header="Sessão" style="min-width: 160px">
<template #body="{ data }">{{ sessionTitle(data) }}</template>
</Column>
<Column field="modalidade" header="Modalidade" style="min-width: 110px">
<template #body="{ data }">
{{ data.modalidade === 'online' ? 'Online' : data.modalidade === 'presencial' ? 'Presencial' : data.modalidade || '—' }}
</template>
</Column>
<Column field="status" header="Status" style="min-width: 110px">
<template #body="{ data }">
<Tag
:value="STATUS_LABEL[data.status] || data.status || 'Agendado'"
:severity="STATUS_SEVERITY[data.status] || 'info'"
/>
</template>
</Column>
</DataTable>
</div>
</div><!-- /.mr-charts-row -->
</div>
</div>
</section>
</template>
<style scoped>
.mr-page {
position: absolute;
inset: 6px 6px calc(var(--m-dock-h, 76px) + 6px) 6px;
z-index: 40;
display: flex;
flex-direction: column;
background: var(--m-bg-medium);
backdrop-filter: blur(32px) saturate(160%);
-webkit-backdrop-filter: blur(32px) saturate(160%);
border: 1px solid var(--m-border);
border-radius: 18px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.4);
overflow: hidden;
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
color: var(--m-text);
animation: mr-page-enter 240ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
@keyframes mr-page-enter {
from { opacity: 0; transform: scale(0.985); }
to { opacity: 1; transform: scale(1); }
}
.mr-page__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--m-border);
flex-shrink: 0;
gap: 10px;
}
.mr-page__title {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
font-size: 1rem;
font-weight: 500;
}
.mr-page__title-icon { color: var(--p-primary-color); font-size: 1.05rem; }
.mr-page__title > span:not(.mr-page__count) {
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.mr-page__count {
font-size: 0.7rem;
font-weight: 600;
color: var(--m-accent);
background: var(--m-accent-soft);
border: 1px solid color-mix(in srgb, var(--m-accent) 35%, transparent);
padding: 2px 8px;
border-radius: 999px;
text-transform: capitalize;
}
.mr-page__actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.mr-close, .mr-head-btn {
width: 32px; height: 32px;
display: grid; place-items: center;
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
color: var(--m-text);
border-radius: 9px;
cursor: pointer;
font-family: inherit;
transition: background-color 140ms ease;
}
.mr-close:hover, .mr-head-btn:hover { background: var(--m-bg-soft-hover); }
.mr-head-btn > i { font-size: 0.85rem; }
.mr-head-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.mr-act-btn {
display: inline-flex;
align-items: center;
gap: 6px;
height: 32px;
padding: 0 12px;
border-radius: 9px;
cursor: pointer;
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
color: var(--m-text);
transition: background-color 140ms ease;
}
.mr-act-btn:hover { background: var(--m-bg-soft-hover); }
.mr-act-btn > i { font-size: 0.78rem; }
/* Subheader */
.mr-subheader {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px 18px;
border-bottom: 1px solid var(--m-border);
background: var(--m-bg-soft);
font-size: 0.78rem;
color: var(--m-text-muted);
line-height: 1.45;
flex-shrink: 0;
}
.mr-subheader__icon { color: var(--p-primary-color); font-size: 0.92rem; flex-shrink: 0; margin-top: 1px; }
.mr-subheader__text { flex: 1; min-width: 0; }
.mr-subheader__text strong { color: var(--m-text); font-weight: 600; }
/* Body */
.mr-body {
flex: 1;
display: flex;
min-height: 0;
gap: 0;
padding: 0;
}
/* ─── Sidebar ─── */
.mr-side {
width: 280px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: var(--m-bg-soft);
border-right: 1px solid var(--m-border);
overflow: hidden;
}
.mr-side__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.mr-side__scroll::-webkit-scrollbar { width: 5px; }
.mr-side__scroll::-webkit-scrollbar-thumb { background: var(--m-border-strong); border-radius: 3px; }
.mr-side__footer {
flex-shrink: 0;
padding: 12px;
background: var(--m-bg-soft);
border-top: 1px solid var(--m-border);
}
.mr-side__clear-all {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 9px 12px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
color: var(--m-text);
border-radius: 10px;
cursor: pointer;
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
transition: background-color 140ms ease;
}
.mr-side__clear-all:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mr-side__clear-all > i { font-size: 0.78rem; color: var(--m-text-muted); }
.mr-side__clear-inline {
width: 18px; height: 18px;
display: grid; place-items: center;
background: transparent;
border: 1px solid color-mix(in srgb, rgb(220, 38, 38) 30%, var(--m-border));
color: rgb(220, 38, 38);
border-radius: 4px;
cursor: pointer;
transition: background-color 140ms ease, border-color 140ms ease;
}
.mr-side__clear-inline:hover {
background: rgba(220, 38, 38, 0.10);
border-color: rgba(220, 38, 38, 0.55);
}
.mr-side__clear-inline > i { font-size: 0.6rem; }
.mr-clear-enter-active,
.mr-clear-leave-active {
transition: opacity 220ms ease, transform 220ms ease, max-height 240ms ease;
overflow: hidden;
}
.mr-clear-enter-from, .mr-clear-leave-to {
opacity: 0; transform: translateY(6px); max-height: 0;
}
.mr-clear-enter-to, .mr-clear-leave-from {
opacity: 1; transform: translateY(0); max-height: 80px;
}
.mr-w {
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 12px;
padding: 12px;
}
.mr-w--side {
margin: 12px 12px 0;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
.mr-w--side:last-of-type { margin-bottom: 12px; }
.mr-w__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.mr-w__title {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--m-text-muted);
font-weight: 600;
}
.mr-w__title > i { color: var(--m-text-muted); font-size: 0.7rem; }
.mr-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px;
}
.mr-stat {
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 10px;
padding: 8px 10px;
}
.mr-stat__val {
font-size: 1.1rem;
font-weight: 700;
line-height: 1.1;
color: var(--m-text);
}
.mr-stat__lbl {
font-size: 0.62rem;
color: var(--m-text-muted);
margin-top: 4px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.mr-stat.is-ok .mr-stat__val { color: rgb(22, 163, 74); }
.mr-stat.is-danger .mr-stat__val { color: rgb(220, 38, 38); }
.mr-stat.is-warn .mr-stat__val { color: rgb(217, 119, 6); }
.mr-stat.is-info .mr-stat__val { color: rgb(2, 132, 199); }
/* Filter buttons */
.mr-side__list {
display: flex;
flex-direction: column;
gap: 4px;
}
.mr-side__item {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
background: transparent;
border: 1px solid transparent;
color: var(--m-text);
border-radius: 10px;
cursor: pointer;
font-family: inherit;
font-size: 0.82rem;
text-align: left;
transition: background-color 140ms ease, border-color 140ms ease, box-shadow 140ms ease;
}
.mr-side__item > i {
font-size: 0.78rem;
width: 14px;
text-align: center;
color: var(--m-text-muted);
}
.mr-side__item:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mr-side__item.is-active.is-period {
background: color-mix(in srgb, var(--p-primary-color) 12%, transparent);
border-color: color-mix(in srgb, var(--p-primary-color) 50%, transparent);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--p-primary-color) 30%, transparent);
}
.mr-side__item.is-active.is-period > i,
.mr-side__item.is-active.is-period > span { color: var(--p-primary-color); }
/* Status colors: realizado green / faltou red / cancelado warn / agendado info */
.mr-side__item.is-status-realizado {
background: rgba(22, 163, 74, 0.05);
border-color: rgba(22, 163, 74, 0.18);
}
.mr-side__item.is-status-realizado > i { color: rgb(22, 163, 74); }
.mr-side__item.is-status-realizado:hover {
background: rgba(22, 163, 74, 0.10);
border-color: rgba(22, 163, 74, 0.30);
}
.mr-side__item.is-active.is-status-realizado {
background: rgba(22, 163, 74, 0.16);
border-color: rgba(22, 163, 74, 0.55);
box-shadow: 0 0 0 1px rgba(22, 163, 74, 0.35);
}
.mr-side__item.is-status-faltou {
background: rgba(220, 38, 38, 0.05);
border-color: rgba(220, 38, 38, 0.18);
}
.mr-side__item.is-status-faltou > i { color: rgb(220, 38, 38); }
.mr-side__item.is-status-faltou:hover {
background: rgba(220, 38, 38, 0.10);
border-color: rgba(220, 38, 38, 0.30);
}
.mr-side__item.is-active.is-status-faltou {
background: rgba(220, 38, 38, 0.16);
border-color: rgba(220, 38, 38, 0.55);
box-shadow: 0 0 0 1px rgba(220, 38, 38, 0.35);
}
.mr-side__item.is-status-cancelado {
background: rgba(217, 119, 6, 0.05);
border-color: rgba(217, 119, 6, 0.18);
}
.mr-side__item.is-status-cancelado > i { color: rgb(217, 119, 6); }
.mr-side__item.is-status-cancelado:hover {
background: rgba(217, 119, 6, 0.10);
border-color: rgba(217, 119, 6, 0.30);
}
.mr-side__item.is-active.is-status-cancelado {
background: rgba(217, 119, 6, 0.16);
border-color: rgba(217, 119, 6, 0.55);
box-shadow: 0 0 0 1px rgba(217, 119, 6, 0.35);
}
.mr-side__item.is-status-agendado {
background: rgba(2, 132, 199, 0.05);
border-color: rgba(2, 132, 199, 0.18);
}
.mr-side__item.is-status-agendado > i { color: rgb(2, 132, 199); }
.mr-side__item.is-status-agendado:hover {
background: rgba(2, 132, 199, 0.10);
border-color: rgba(2, 132, 199, 0.30);
}
.mr-side__item.is-active.is-status-agendado {
background: rgba(2, 132, 199, 0.16);
border-color: rgba(2, 132, 199, 0.55);
box-shadow: 0 0 0 1px rgba(2, 132, 199, 0.35);
}
/* ─── Main ─── */
.mr-main {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
padding: 12px;
gap: 12px;
overflow-y: auto;
}
.mr-error {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
background: rgba(220, 38, 38, 0.10);
border: 1px solid rgba(220, 38, 38, 0.30);
border-radius: 10px;
color: rgb(220, 38, 38);
font-size: 0.82rem;
flex-shrink: 0;
}
/* Row 50/50 — chart + tabela lado a lado em desktop */
.mr-charts-row {
display: flex;
flex-direction: row;
gap: 12px;
flex: 1;
min-height: 0;
}
.mr-charts-row > .mr-card {
flex: 1 1 50%;
min-width: 0;
min-height: 0;
}
/* Card-base */
.mr-card {
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.mr-card--chart {
display: flex;
flex-direction: column;
}
.mr-card--chart .mr-card__body {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.mr-card--chart .mr-chart-wrap,
.mr-card--chart .mr-chart-skel {
flex: 1;
height: auto;
min-height: 200px;
}
.mr-card--table {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.mr-card__head {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-bottom: 1px solid var(--m-border);
}
.mr-card__icon {
width: 36px; height: 36px;
display: grid; place-items: center;
border-radius: 9px;
background: color-mix(in srgb, var(--p-primary-color) 15%, transparent);
color: var(--p-primary-color);
font-size: 1rem;
flex-shrink: 0;
}
.mr-card__title { flex: 1; min-width: 0; }
.mr-card__title-text {
font-size: 0.92rem;
font-weight: 700;
color: var(--m-text);
}
.mr-card__sub {
font-size: 0.74rem;
color: var(--m-text-muted);
margin-top: 2px;
}
.mr-card__sub strong { color: var(--m-text); font-weight: 600; }
.mr-card__count {
font-size: 0.72rem;
font-weight: 700;
color: white;
background: var(--p-primary-color);
padding: 3px 10px;
border-radius: 999px;
min-width: 28px;
text-align: center;
}
.mr-card__body {
padding: 14px 16px;
}
/* Chart */
.mr-chart-wrap { height: 260px; }
.mr-chart-skel { height: 260px; }
.mr-empty-inline {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 32px 20px;
color: var(--m-text-muted);
font-size: 0.85rem;
}
/* Empty */
.mr-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 56px 28px;
text-align: center;
color: var(--m-text-muted);
gap: 8px;
flex: 1;
}
.mr-empty__icon { font-size: 2rem; color: var(--m-text-faint); margin-bottom: 4px; }
.mr-empty__title { font-size: 0.92rem; font-weight: 600; color: var(--m-text); }
.mr-empty__hint { font-size: 0.78rem; }
.mr-empty__btn { margin-top: 8px; }
/* DataTable */
.mr-table {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.mr-table :deep(.p-datatable) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: transparent;
border: none;
border-radius: 0;
overflow: hidden;
}
.mr-table :deep(.p-datatable-table-container) { flex: 1; min-height: 0; background: transparent; }
.mr-table :deep(.p-datatable-thead),
.mr-table :deep(.p-datatable-thead > tr) { background: transparent !important; }
.mr-table :deep(.p-datatable-thead > tr > th) {
background: var(--m-bg-medium) !important;
color: var(--m-text);
font-size: 0.74rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
padding: 10px 14px;
border-bottom: 1px solid var(--m-border);
}
.mr-table :deep(.p-datatable-tbody > tr) {
background: transparent;
transition: background-color 140ms ease;
}
.mr-table :deep(.p-datatable-tbody > tr > td) {
padding: 10px 14px;
border-bottom: 1px solid var(--m-border);
background: transparent;
font-size: 0.82rem;
}
.mr-table :deep(.p-datatable-tbody > tr:hover) { background: var(--m-bg-soft-hover); }
.mr-table :deep(.p-paginator) {
background: var(--m-bg-medium);
border: none;
border-top: 1px solid var(--m-border);
padding: 8px 12px;
justify-content: center;
flex-wrap: wrap;
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;
display: flex;
flex-direction: column;
}
.mr-mobile-drawer.is-open { transform: translateX(0); }
.mr-mobile-drawer__scroll {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.mr-mobile-drawer__scroll .mr-side {
flex: 1;
min-height: 0;
width: 100%;
overflow: hidden;
background: transparent;
border-right: none;
display: flex;
flex-direction: column;
}
.mr-mobile-drawer__scroll .mr-side__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 12px;
display: flex;
flex-direction: column;
gap: 12px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mr-mobile-drawer__scroll .mr-side__scroll::-webkit-scrollbar { width: 5px; }
.mr-mobile-drawer__scroll .mr-side__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.mr-mobile-drawer__scroll .mr-w--side {
margin: 0;
flex-shrink: 0;
}
.mr-mobile-drawer__scroll .mr-w--side:last-of-type { margin-bottom: 0; }
.mr-mobile-drawer__scroll .mr-side__footer {
flex-shrink: 0;
margin: 0;
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%);
}
.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-main { width: 100%; padding: 8px; }
.mr-page__title > span:first-of-type { display: none; }
.mr-page__title-icon { display: none; }
.mr-menu-btn--mobile-only { display: inline-flex; }
.mr-stats { grid-template-columns: repeat(3, 1fr); }
/* Em mobile empilha chart + tabela; tabela ganha min-height pra
nao colapsar pra ~50px em telas curtas. */
.mr-charts-row {
flex-direction: column;
flex: 1 0 auto;
}
.mr-card--chart .mr-chart-wrap,
.mr-card--chart .mr-chart-skel {
height: 240px;
min-height: 240px;
}
.mr-card--table {
min-height: 360px;
flex: 1 0 360px;
}
}
</style>