Files
agenciapsilmno/src/layout/melissa/MelissaPacientes.vue
T
Leonardo 02af119dc6 Melissa drawers: footer colado no bottom (pattern AppMenu)
Refator do mobile drawer em todas as Melissa Pages com sidebar:
scroll move pra dentro de .xx-side__scroll (flex: 1 + min-height: 0)
e o __footer vira flex-shrink: 0 last child de flex column. Espelha
o pattern do AppMenu/layout-sidebar Rail. Substitui o sticky/margin:auto
que falhava quando o conteudo era pequeno (deixava espaco vazio sob
o "Limpar filtros").

Pages: Compromissos, Conversas, Documentos, FinanceiroLancamentos,
Grupos, Medicos, Notificacoes, Pacientes, Recorrencias, Relatorios, Tags.

Pacientes (caso especial): mp-quick fixo no topo (max-height: 50%)
+ mp-side flex: 1 com scroll/footer interno.

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

3022 lines
109 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
/*
* MelissaPacientes — Sessão Pacientes como página fullscreen dentro de Melissa
* --------------------------------------------------
* Mesma convenção das "Melissa Pages" (espelha MelissaAgenda):
* - 6px de padding do viewport
* - Glass container com cantos arredondados
* - Bottom inset reservado pra .melissa-dock (76px)
*
* Layout 3-col:
* - COL 1 — Aside esquerda (~240px): filtros (status / grupos / tags)
* - COL 2 — Lista central (flex 1): search + cards de pacientes
* - COL 3 — Quick view direita (~320px): KPIs + paciente selecionado + ações
*
* Dados:
* - useMelissaPacientes({ onlyActive: false }) — traz todos os status
* - patient_groups + patient_tags do tenant + vínculos patient_group_patient
* e patient_patient_tag (via patientsRepository.listGroupsByPatient/Tags...)
*
* Integrações:
* - PatientProntuario (overlay dialog) — abre via duplo-click no card ou
* botão "Abrir prontuário" da COL 3
* - PatientCadastroDialog — cadastro completo / edição
* - PatientCreatePopover + ComponentCadastroRapido — fluxo de novo paciente
* - conversationDrawerStore — botão WhatsApp da COL 3
*/
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { useMelissaPacientes } from './composables/useMelissaPacientes';
import { useTenantStore } from '@/stores/tenantStore';
import { supabase } from '@/lib/supabase/client';
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
import {
listGroups as repoListGroups,
listTags as repoListTags,
listGroupsByPatient,
listTagsByPatient,
getSessionCounts,
softDeletePatient
} from '@/features/patients/services/patientsRepository';
import { usePatientLifecycle } from '@/composables/usePatientLifecycle';
import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue';
import PatientCreatePopover from '@/components/ui/PatientCreatePopover.vue';
import PatientCadastroDialog from '@/components/ui/PatientCadastroDialog.vue';
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue';
// Dialog do PrimeVue: auto-importado via PrimeVueResolver — NÃO importar
// explicitamente (causa "emitsOptions: null" por instância duplicada).
const emit = defineEmits(['close', 'patient-created', 'goto-agenda', 'goto-grupos', 'goto-tags']);
const toast = useToast();
const confirm = useConfirm();
const tenantStore = useTenantStore();
const { reactivatePatient } = usePatientLifecycle();
const conversationDrawerStore = useConversationDrawerStore();
// ── Pacientes (todos os status) ───────────────────────────────
const { pacientes, loading: loadingPacientes, refetch: refetchPacientes } =
useMelissaPacientes({ onlyActive: false });
// Computed pra skeleton só na 1ª carga (pattern do blueprint §9)
const pacientesCarregandoInicial = computed(
() => loadingPacientes.value && pacientes.value.length === 0
);
// ── Breakpoints + drawer mobile (blueprint §2/§3) ─────────────
// <lg (≤1023px) → "mobile" — .mp-side e .mp-quick saem do layout
// e viajam pro drawer off-canvas via Teleport
const drawerOpen = ref(false);
const isMobile = ref(false);
const isCompact = ref(false);
let _mqMobile = null;
let _mqCompact = null;
function _onMqMobileChange(e) {
isMobile.value = e.matches;
if (!e.matches) drawerOpen.value = false;
}
function _onMqCompactChange(e) {
isCompact.value = e.matches;
}
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
function fecharDrawer() { drawerOpen.value = false; }
// ── Grupos / Tags (catálogo + vínculos por paciente) ──────────
const grupos = ref([]); // [{ id, nome, cor, ... }]
const tags = ref([]); // [{ id, nome, cor, ... }]
const groupsByPatient = ref(new Map()); // patientId → [{id,nome,cor}]
const tagsByPatient = ref(new Map()); // patientId → [{id,nome,cor}]
const sessionCountByPatient = ref(new Map()); // patientId → count
// Contagem reversa: groupId/tagId → nº pacientes vinculados.
// Usa Set de patientIds pra evitar contar duplicado se o vínculo aparecer
// duas vezes na lista (defesa). Recomputa quando groupsByPatient/tagsByPatient
// mudam (após loadVinculos).
const groupCounts = computed(() => {
const map = new Map();
for (const [pid, gs] of groupsByPatient.value) {
for (const g of (gs || [])) {
if (!map.has(g.id)) map.set(g.id, new Set());
map.get(g.id).add(pid);
}
}
const out = new Map();
for (const [gid, set] of map) out.set(gid, set.size);
return out;
});
const tagCounts = computed(() => {
const map = new Map();
for (const [pid, ts] of tagsByPatient.value) {
for (const t of (ts || [])) {
if (!map.has(t.id)) map.set(t.id, new Set());
map.get(t.id).add(pid);
}
}
const out = new Map();
for (const [tid, set] of map) out.set(tid, set.size);
return out;
});
async function loadCatalogos() {
if (typeof tenantStore.ensureLoaded === 'function') {
await tenantStore.ensureLoaded();
}
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || null;
if (!tenantId) return;
const { data: u } = await supabase.auth.getUser();
const ownerId = u?.user?.id || null;
try {
const [g, t] = await Promise.all([
repoListGroups({ tenantId, ownerId }),
repoListTags({ tenantId, ownerId })
]);
grupos.value = g || [];
tags.value = t || [];
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[MelissaPacientes] catálogo grupos/tags falhou:', e?.message);
}
}
async function loadVinculos() {
const ids = pacientes.value.map((p) => p.id).filter(Boolean);
if (!ids.length) {
groupsByPatient.value = new Map();
tagsByPatient.value = new Map();
sessionCountByPatient.value = new Map();
return;
}
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || null;
try {
const [pg, pt, sc] = await Promise.all([
listGroupsByPatient(ids, { tenantId }),
listTagsByPatient(ids, { tenantId }),
getSessionCounts(ids)
]);
const gById = new Map(grupos.value.map((g) => [g.id, g]));
const gMap = new Map();
for (const rel of (pg || [])) {
const arr = gMap.get(rel.patient_id) || [];
const g = gById.get(rel.patient_group_id);
if (g) arr.push(g);
gMap.set(rel.patient_id, arr);
}
groupsByPatient.value = gMap;
const tById = new Map(tags.value.map((t) => [t.id, t]));
const tMap = new Map();
for (const rel of (pt || [])) {
const arr = tMap.get(rel.patient_id) || [];
const t = tById.get(rel.tag_id);
if (t) arr.push(t);
tMap.set(rel.patient_id, arr);
}
tagsByPatient.value = tMap;
const cMap = new Map();
for (const r of (sc || [])) {
if (r?.patient_id) cMap.set(r.patient_id, r.session_count || 0);
}
sessionCountByPatient.value = cMap;
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[MelissaPacientes] vínculos falharam:', e?.message);
}
}
// ── Criar grupo/tag (dialogs simples) ─────────────────────────
// Espelhado de PatientsCadastroPage: insert direto em patient_groups /
// patient_tags com owner_id + tenant_id (RLS exige), nome e cor.
const createGroupOpen = ref(false);
const createGroupSaving = ref(false);
const createGroupError = ref('');
const newGroupForm = ref({ name: '', color: '#6366F1' });
const createTagOpen = ref(false);
const createTagSaving = ref(false);
const createTagError = ref('');
const newTagForm = ref({ name: '', color: '#22C55E' });
function openCreateGroup() {
createGroupError.value = '';
newGroupForm.value = { name: '', color: '#6366F1' };
createGroupOpen.value = true;
}
function openCreateTag() {
createTagError.value = '';
newTagForm.value = { name: '', color: '#22C55E' };
createTagOpen.value = true;
}
async function _ownerAndTenant() {
if (typeof tenantStore.ensureLoaded === 'function') await tenantStore.ensureLoaded();
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || null;
const { data: u } = await supabase.auth.getUser();
const ownerId = u?.user?.id || null;
if (!ownerId || !tenantId) throw new Error('Sessão não inicializada.');
return { ownerId, tenantId };
}
async function persistGroup() {
if (createGroupSaving.value) return;
const name = String(newGroupForm.value.name || '').trim();
const color = String(newGroupForm.value.color || '').trim() || '#6366F1';
if (!name) { createGroupError.value = 'Informe um nome.'; return; }
createGroupSaving.value = true;
createGroupError.value = '';
try {
const { ownerId, tenantId } = await _ownerAndTenant();
const { error } = await supabase.from('patient_groups').insert({
owner_id: ownerId, tenant_id: tenantId,
nome: name, cor: color,
is_system: false, is_active: true
});
if (error) throw error;
await loadCatalogos();
toast.add({ severity: 'success', summary: 'Grupo criado', life: 2200 });
createGroupOpen.value = false;
} catch (e) {
const msg = e?.message || '';
createGroupError.value = (e?.code === '23505' || /duplicate/i.test(msg)) ? 'Já existe esse grupo.' : (msg || 'Falha ao criar grupo.');
} finally {
createGroupSaving.value = false;
}
}
async function persistTag() {
if (createTagSaving.value) return;
const name = String(newTagForm.value.name || '').trim();
const color = String(newTagForm.value.color || '').trim() || '#22C55E';
if (!name) { createTagError.value = 'Informe um nome.'; return; }
createTagSaving.value = true;
createTagError.value = '';
try {
const { ownerId, tenantId } = await _ownerAndTenant();
const { error } = await supabase.from('patient_tags').insert({
owner_id: ownerId, tenant_id: tenantId,
nome: name, cor: color
});
if (error) throw error;
await loadCatalogos();
toast.add({ severity: 'success', summary: 'Tag criada', life: 2200 });
createTagOpen.value = false;
} catch (e) {
const msg = e?.message || '';
createTagError.value = (e?.code === '23505' || /duplicate/i.test(msg)) ? 'Já existe essa tag.' : (msg || 'Falha ao criar tag.');
} finally {
createTagSaving.value = false;
}
}
onMounted(() => {
loadCatalogos();
if (typeof window !== 'undefined' && window.matchMedia) {
_mqMobile = window.matchMedia('(max-width: 1023px)');
isMobile.value = _mqMobile.matches;
_mqMobile.addEventListener('change', _onMqMobileChange);
_mqCompact = window.matchMedia('(max-width: 1279px)');
isCompact.value = _mqCompact.matches;
_mqCompact.addEventListener('change', _onMqCompactChange);
}
});
onBeforeUnmount(() => {
if (_mqMobile) _mqMobile.removeEventListener('change', _onMqMobileChange);
if (_mqCompact) _mqCompact.removeEventListener('change', _onMqCompactChange);
});
// Watcher: quando lista de pacientes mudar (mount inicial, refetch após
// criar/editar), recarrega vínculos.
watch(
() => pacientes.value.length,
() => loadVinculos()
);
async function refetchTudo() {
await refetchPacientes();
await loadVinculos();
emit('patient-created'); // avisa parent pra refrescar pacientesReais
}
// ── Estado de UI ───────────────────────────────────────────────
const busca = ref('');
const statusFiltro = ref('ativos'); // 'todos' | 'ativos' | 'inativos' | 'arquivados'
const grupoFiltroId = ref(null); // null = todos
const tagFiltroId = ref(null); // null = todas
const pacienteSelecionadoId = ref(null);
// ── Helpers ────────────────────────────────────────────────────
function normalize(s) {
return String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim();
}
function pacienteIniciais(nome) {
if (!nome) return '?';
const partes = String(nome).trim().split(/\s+/);
if (partes.length === 1) return partes[0][0]?.toUpperCase() || '?';
return (partes[0][0] + partes[partes.length - 1][0]).toUpperCase();
}
function fmtUltimaSessao(iso) {
if (!iso) return 'Sem atendimento';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return 'Sem atendimento';
const dia = String(d.getDate()).padStart(2, '0');
const mes = String(d.getMonth() + 1).padStart(2, '0');
return `${dia}/${mes}`;
}
const NOVO_THRESHOLD_MS = 7 * 24 * 60 * 60 * 1000;
function isPacienteNovo(p) {
if (!p?.created_at) return false;
const t = new Date(p.created_at).getTime();
if (Number.isNaN(t)) return false;
return Date.now() - t < NOVO_THRESHOLD_MS;
}
function fmtPhone(p) {
const v = String(p || '').replace(/\D/g, '');
if (v.length === 11) return `(${v.slice(0, 2)}) ${v.slice(2, 7)}-${v.slice(7)}`;
if (v.length === 10) return `(${v.slice(0, 2)}) ${v.slice(2, 6)}-${v.slice(6)}`;
return p || '';
}
// Aniversário no mês corrente — flag visual no detail
function aniversarioEsteMes(iso) {
if (!iso) return false;
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return false;
return d.getMonth() === new Date().getMonth();
}
// ── Status: contagens + filtragem ──────────────────────────────
const STATUS_OPTIONS = [
{ key: 'todos', label: 'Todos', icon: 'pi pi-users' },
{ key: 'ativos', label: 'Ativos', icon: 'pi pi-check-circle' },
{ key: 'inativos', label: 'Inativos', icon: 'pi pi-pause-circle' },
{ key: 'arquivados', label: 'Arquivados', icon: 'pi pi-inbox' }
];
const contagens = computed(() => {
const c = { todos: 0, ativos: 0, inativos: 0, arquivados: 0 };
for (const p of pacientes.value) {
c.todos++;
const st = String(p.status || '').toLowerCase();
if (st === 'ativo') c.ativos++;
else if (st === 'inativo') c.inativos++;
else if (st === 'arquivado') c.arquivados++;
}
return c;
});
const pacientesFiltrados = computed(() => {
const q = normalize(busca.value);
const filtroStatus = statusFiltro.value;
const filtroGrupo = grupoFiltroId.value;
const filtroTag = tagFiltroId.value;
return pacientes.value.filter((p) => {
// status
if (filtroStatus !== 'todos') {
const st = String(p.status || '').toLowerCase();
if (filtroStatus === 'ativos' && st !== 'ativo') return false;
if (filtroStatus === 'inativos' && st !== 'inativo') return false;
if (filtroStatus === 'arquivados' && st !== 'arquivado') return false;
}
// grupo
if (filtroGrupo) {
const gs = groupsByPatient.value.get(p.id) || [];
if (!gs.some((g) => g.id === filtroGrupo)) return false;
}
// tag
if (filtroTag) {
const ts = tagsByPatient.value.get(p.id) || [];
if (!ts.some((t) => t.id === filtroTag)) return false;
}
// busca
if (!q) return true;
return normalize(p.nome).includes(q) || normalize(p.email).includes(q);
});
});
const pacientesOrdenados = computed(() => {
const novos = [];
const resto = [];
for (const p of pacientesFiltrados.value) {
if (isPacienteNovo(p)) novos.push(p);
else resto.push(p);
}
novos.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
return [...novos, ...resto];
});
// ── Paginação da lista (client-side, default 10 por página) ───────
// `useMelissaPacientes` já trouxe a lista completa em memória, então
// paginamos no client (sem round-trip). Seletor de tamanho cobre os
// presets típicos — 10/20/50/100. Se o usuário tiver clínica grande
// (>1000 ativos), o composable já capa em 1000 no SQL.
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
const pageMP = ref(1);
const rowsMP = ref(10);
const pacientesPaginados = computed(() => {
const start = (pageMP.value - 1) * rowsMP.value;
return pacientesOrdenados.value.slice(start, start + rowsMP.value);
});
function onPageMPChange(event) {
pageMP.value = event.page + 1;
rowsMP.value = event.rows;
}
// Reset pra página 1 quando qualquer filtro/busca mudar.
watch([busca, statusFiltro, grupoFiltroId, tagFiltroId], () => { pageMP.value = 1; });
// Clamp: se a lista filtrada encolher abaixo da página atual, recua.
watch(() => pacientesOrdenados.value.length, (len) => {
const maxPag = Math.max(1, Math.ceil(len / rowsMP.value));
if (pageMP.value > maxPag) pageMP.value = maxPag;
});
// ── View mode persistido (list/grid) ──────────────────────
// Lista = cards verticais empilhados (default, mostra detail panel).
// Grade = cards num grid responsivo (mais compacto, ideal pra browse).
const VIEW_MODE_KEY = 'mp.viewMode.v1';
const viewMode = ref('list');
try {
const saved = localStorage.getItem(VIEW_MODE_KEY);
if (saved === 'list' || saved === 'grid') viewMode.value = saved;
} catch (_) {}
function setViewMode(m) {
if (m !== 'list' && m !== 'grid') return;
viewMode.value = m;
try { localStorage.setItem(VIEW_MODE_KEY, m); } catch (_) {}
}
// ── Popover de ações (mobile) ────────────────────────────
// Em desktop os 5 botões aparecem inline no card. Em mobile, só
// mostra 1 pencil que dispara este popover com as 5 opções.
const actionsPopRef = ref(null);
const actionsTarget = ref(null);
function openActionsPop(event, p) {
actionsTarget.value = p;
actionsPopRef.value?.toggle(event);
}
function actionsRun(fn) {
const p = actionsTarget.value;
actionsPopRef.value?.hide();
if (p && typeof fn === 'function') fn(p);
}
// ── KPIs (COL 3) ────────────────────────────────────────────────
const RECENTE_THRESHOLD_MS = 14 * 24 * 60 * 60 * 1000;
const kpis = computed(() => {
const total = pacientes.value.length;
let novos = 0;
let recentes = 0;
let aniversariantesMes = 0;
const agora = Date.now();
const mesAtual = new Date().getMonth();
for (const p of pacientes.value) {
if (isPacienteNovo(p)) novos++;
if (p.last_attended_at) {
const t = new Date(p.last_attended_at).getTime();
if (!Number.isNaN(t) && agora - t < RECENTE_THRESHOLD_MS) recentes++;
}
if (p.data_nascimento) {
const d = new Date(p.data_nascimento);
if (!Number.isNaN(d.getTime()) && d.getMonth() === mesAtual) aniversariantesMes++;
}
}
return { total, novos, recentes, aniversariantesMes };
});
// ── Seleção ────────────────────────────────────────────────────
const pacienteSelecionado = computed(() =>
pacientes.value.find((p) => p.id === pacienteSelecionadoId.value) || null
);
const grupoCorById = (id) => grupos.value.find((g) => g.id === id)?.color || null;
const tagCorById = (id) => tags.value.find((t) => t.id === id)?.color || null;
const selectedGroups = computed(() => {
if (!pacienteSelecionadoId.value) return [];
return groupsByPatient.value.get(pacienteSelecionadoId.value) || [];
});
const selectedTags = computed(() => {
if (!pacienteSelecionadoId.value) return [];
return tagsByPatient.value.get(pacienteSelecionadoId.value) || [];
});
const selectedSessionCount = computed(() => {
if (!pacienteSelecionadoId.value) return 0;
return sessionCountByPatient.value.get(pacienteSelecionadoId.value) || 0;
});
function selecionarPaciente(id) {
pacienteSelecionadoId.value = pacienteSelecionadoId.value === id ? null : id;
}
function limparSelecao() {
pacienteSelecionadoId.value = null;
}
function limparFiltros() {
busca.value = '';
statusFiltro.value = 'ativos';
grupoFiltroId.value = null;
tagFiltroId.value = null;
}
// ── Cadastro / edição / prontuário ─────────────────────────────
const createPopoverRef = ref(null);
const cadastroFullDialog = ref(false);
const quickDialog = ref(false);
const editPatientId = ref(null);
const prontuarioOpen = ref(false);
const prontuarioPatient = ref(null);
function openCreatePopover(e) {
createPopoverRef.value?.toggle(e);
}
function openQuickCreate() {
quickDialog.value = true;
}
function goCreateFull() {
editPatientId.value = null;
cadastroFullDialog.value = true;
}
function onPatientCreated() {
refetchTudo();
}
function abrirProntuario(p) {
if (!p) return;
prontuarioPatient.value = { ...p };
prontuarioOpen.value = true;
}
function editarPaciente(p) {
if (!p?.id) return;
editPatientId.value = String(p.id);
cadastroFullDialog.value = true;
}
function abrirWhatsapp(p) {
if (!p?.id) {
toast.add({ severity: 'warn', summary: 'Paciente sem id', life: 2000 });
return;
}
conversationDrawerStore.openForPatient(String(p.id));
}
function confirmarRemover(p) {
if (!p?.id) return;
confirm.require({
message: `Arquivar "${p.nome}"? O paciente sairá da lista ativa, mas pode ser restaurado depois.`,
header: 'Arquivar paciente',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Arquivar',
rejectLabel: 'Cancelar',
acceptClass: 'p-button-danger',
accept: async () => {
try {
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId;
await softDeletePatient(p.id, { tenantId });
toast.add({ severity: 'success', summary: 'Arquivado', detail: p.nome, life: 2200 });
if (pacienteSelecionadoId.value === p.id) limparSelecao();
await refetchTudo();
} catch (e) {
toast.add({
severity: 'error',
summary: 'Falha ao arquivar',
detail: e?.message || 'Tente novamente.',
life: 4000
});
}
}
});
}
// Helper: paciente está arquivado? (case-insensitive — DB pode vir
// com "Arquivado" do form ou "arquivado" lowercased de algum lugar.)
function isArquivado(p) {
return String(p?.status || '').toLowerCase() === 'arquivado';
}
// Restaurar paciente arquivado — volta status pra 'Ativo' via
// reactivatePatient (compartilhado com PatientActionMenu/PatientsListPage,
// fonte única em usePatientLifecycle).
function restaurarPaciente(p) {
if (!p?.id) return;
confirm.require({
message: `Restaurar "${p.nome}" pra lista de pacientes ativos?`,
header: 'Restaurar paciente',
icon: 'pi pi-undo',
acceptLabel: 'Restaurar',
rejectLabel: 'Cancelar',
accept: async () => {
try {
const result = await reactivatePatient(p.id);
if (!result?.ok) throw result?.error || new Error('Falha ao restaurar.');
toast.add({ severity: 'success', summary: 'Restaurado', detail: p.nome, life: 2200 });
await refetchTudo();
} catch (e) {
toast.add({
severity: 'error',
summary: 'Falha ao restaurar',
detail: e?.message || 'Tente novamente.',
life: 4000
});
}
}
});
}
// ── Sessões do paciente (dialog) ───────────────────────────────
const sessoesDialogOpen = ref(false);
const sessoesPaciente = ref(null);
const sessoesLista = ref([]);
const sessoesLoading = ref(false);
async function verSessoes(p) {
if (!p?.id) return;
sessoesPaciente.value = p;
sessoesDialogOpen.value = true;
sessoesLista.value = [];
sessoesLoading.value = true;
try {
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId;
const { data, error } = await supabase
.from('agenda_eventos')
.select('id, inicio_em, fim_em, tipo, status, titulo, modalidade')
.eq('tenant_id', tenantId)
.eq('patient_id', p.id)
.order('inicio_em', { ascending: false })
.limit(50);
if (error) throw error;
sessoesLista.value = data || [];
} catch (e) {
toast.add({
severity: 'error',
summary: 'Falha ao carregar sessões',
detail: e?.message || 'Tente novamente.',
life: 4000
});
} finally {
sessoesLoading.value = false;
}
}
function fmtSessaoData(iso) {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '—';
const dia = String(d.getDate()).padStart(2, '0');
const mes = String(d.getMonth() + 1).padStart(2, '0');
const ano = d.getFullYear();
const h = String(d.getHours()).padStart(2, '0');
const min = String(d.getMinutes()).padStart(2, '0');
return `${dia}/${mes}/${ano} ${h}:${min}`;
}
function sessaoStatusLabel(s) {
const v = String(s || '').toLowerCase();
if (v === 'realizado' || v === 'realizada') return 'Realizado';
if (v === 'faltou') return 'Falta';
if (v === 'cancelado' || v === 'cancelada') return 'Cancelado';
if (v === 'remarcar') return 'Remarcar';
if (v === 'agendado') return 'Agendado';
return s || '—';
}
function sessaoStatusColor(s) {
const v = String(s || '').toLowerCase();
if (v === 'realizado' || v === 'realizada') return 'text-emerald-400';
if (v === 'faltou') return 'text-red-400';
if (v === 'cancelado' || v === 'cancelada') return 'text-white/40';
if (v === 'remarcar') return 'text-amber-400';
return 'text-blue-300';
}
</script>
<template>
<!-- Drawer host (multi-root, fora do .mp-page pra full viewport height
e single-scroll). Pattern do blueprint §2. -->
<aside
class="mp-mobile-drawer"
:class="{ 'is-open': drawerOpen }"
v-show="isMobile"
aria-label="Filtros e detalhes do paciente"
>
<div id="mp-mobile-drawer-target" class="mp-mobile-drawer__scroll" />
</aside>
<Transition name="mp-drawer-fade">
<div
v-if="isMobile && drawerOpen"
class="mp-mobile-drawer__backdrop"
@click="fecharDrawer"
/>
</Transition>
<section class="mp-page">
<!-- Header -->
<header class="mp-page__head">
<!-- Menu (mobile only) abre o drawer com filtros + quickview -->
<button
class="mp-menu-btn mp-menu-btn--mobile-only"
v-tooltip.bottom="'Filtros & detalhes'"
@click="toggleDrawer"
>
<i class="pi pi-bars" />
<span>Menu Pacientes</span>
</button>
<div class="mp-page__title">
<i class="pi pi-users mp-page__title-icon" />
<span>Pacientes</span>
<span class="mp-page__count">{{ pacientesOrdenados.length }}</span>
<span v-if="loadingPacientes" class="mp-page__loading">
<i class="pi pi-spin pi-spinner text-xs" />
</span>
</div>
<div class="mp-page__actions">
<!-- Ir pra Agenda icon-only 32×32 (blueprint §11) -->
<button
class="mp-header-btn"
v-tooltip.bottom="'Ir pra Agenda'"
@click="emit('goto-agenda')"
>
<i class="pi pi-calendar" />
</button>
<button class="mp-close" v-tooltip.bottom="'Voltar ao resumo (Esc)'" @click="emit('close')">
<i class="pi pi-times" />
</button>
</div>
</header>
<!-- Subheader explicativo padrão do melissa-table-page-blueprint.md
§ 9. Diferencia visualmente esta página das outras tabulares e
resume as ações principais em <strong>. -->
<div class="mp-subheader">
<i class="pi pi-info-circle mp-subheader__icon" />
<span class="mp-subheader__text">
Lista completa dos pacientes cadastrados. Filtre por
<strong>status, grupo ou tag</strong> à esquerda, busque por
nome ou contato, e clique numa linha pra abrir o
<strong>prontuário</strong> com histórico, sessões e
documentos.
</span>
</div>
<div class="mp-body">
<!-- COL 1: Filtros
Em desktop renderiza in-place. Em mobile (<lg) o Teleport
move pra dentro de #mp-mobile-drawer-target. -->
<Teleport to="#mp-mobile-drawer-target" :disabled="!isMobile">
<aside class="mp-side">
<!-- Wrapper scrollável: cards de filtros (Status / Grupos / Tags).
O footer com "Limpar filtros" fica fora desse scroll
(flex-shrink:0 ancorado no bottom da .mp-side em desktop,
position:sticky no bottom do drawer em mobile). -->
<div class="mp-side__scroll">
<!-- Card: Status -->
<div class="mp-w">
<div class="mp-w__head">
<span class="mp-w__title"><i class="pi pi-circle-fill text-[0.55rem]" /> Status</span>
</div>
<div class="mp-status-list">
<button
v-for="s in STATUS_OPTIONS"
:key="s.key"
class="mp-status-pill"
:class="[`is-${s.key}`, { 'is-active': statusFiltro === s.key }]"
@click="statusFiltro = s.key"
>
<i :class="s.icon" class="text-xs" />
<span class="flex-1 text-left">{{ s.label }}</span>
<span class="mp-status-pill__count">{{ contagens[s.key] }}</span>
</button>
</div>
</div>
<!-- Card: Grupos -->
<div class="mp-w">
<div class="mp-w__head">
<span class="mp-w__title"><i class="pi pi-folder" /> Grupos</span>
<div class="mp-side__head-actions">
<button
v-if="grupoFiltroId"
class="mp-side__clear"
v-tooltip.top="'Limpar filtro de grupo'"
@click="grupoFiltroId = null"
>
<i class="pi pi-times text-[0.6rem]" />
</button>
<button
class="mp-side__cog"
v-tooltip.top="'Configurar grupos'"
@click="emit('goto-grupos')"
>
<i class="pi pi-cog text-[0.62rem]" />
</button>
</div>
</div>
<div class="mp-chip-list">
<button
v-for="g in grupos"
:key="g.id"
class="mp-chip"
:class="{ 'is-active': grupoFiltroId === g.id }"
:style="g.color ? { borderLeftColor: g.color } : null"
@click="grupoFiltroId = grupoFiltroId === g.id ? null : g.id"
>
<span class="mp-chip__dot" :style="{ backgroundColor: g.color || 'var(--m-text-muted)' }" />
<span class="mp-chip__name truncate">{{ g.nome || g.name }}</span>
<span class="mp-chip__count">{{ groupCounts.get(g.id) || 0 }}</span>
</button>
<div v-if="grupos.length === 0" class="mp-side__placeholder">
<span>Nenhum grupo</span>
</div>
</div>
<button
class="mp-side__add-btn"
@click="openCreateGroup"
>
<i class="pi pi-plus" />
<span>Grupo</span>
</button>
</div>
<!-- Card: Tags -->
<div class="mp-w">
<div class="mp-w__head">
<span class="mp-w__title"><i class="pi pi-tag" /> Tags</span>
<div class="mp-side__head-actions">
<button
v-if="tagFiltroId"
class="mp-side__clear"
v-tooltip.top="'Limpar filtro de tag'"
@click="tagFiltroId = null"
>
<i class="pi pi-times text-[0.6rem]" />
</button>
<button
class="mp-side__cog"
v-tooltip.top="'Configurar tags'"
@click="emit('goto-tags')"
>
<i class="pi pi-cog text-[0.62rem]" />
</button>
</div>
</div>
<div class="mp-chip-list">
<button
v-for="t in tags"
:key="t.id"
class="mp-chip"
:class="{ 'is-active': tagFiltroId === t.id }"
:style="t.color ? { borderLeftColor: t.color } : null"
@click="tagFiltroId = tagFiltroId === t.id ? null : t.id"
>
<span class="mp-chip__dot" :style="{ backgroundColor: t.color || 'var(--m-text-muted)' }" />
<span class="mp-chip__name truncate">{{ t.nome || t.name }}</span>
<span class="mp-chip__count">{{ tagCounts.get(t.id) || 0 }}</span>
</button>
<div v-if="tags.length === 0" class="mp-side__placeholder">
<span>Nenhuma tag</span>
</div>
</div>
<button
class="mp-side__add-btn"
@click="openCreateTag"
>
<i class="pi pi-plus" />
<span>Tag</span>
</button>
</div>
</div><!-- /.mp-side__scroll -->
<!-- Footer fixo no bottom da sidebar (fora do scroll dos cards).
Aparece com fade+collapse quando algum filtro é ativado. -->
<Transition name="mp-clear">
<div
v-if="busca || grupoFiltroId || tagFiltroId || statusFiltro !== 'ativos'"
class="mp-side__footer"
>
<button class="mp-btn mp-btn--ghost mp-btn--block" @click="limparFiltros">
<i class="pi pi-filter-slash text-xs" />
Limpar filtros
</button>
</div>
</Transition>
</aside>
</Teleport>
<!-- COL 2: Lista central -->
<div class="mp-list">
<div class="mp-list__toolbar">
<div class="mp-search">
<i class="pi pi-search mp-search__icon" />
<input
v-model="busca"
type="text"
placeholder="Buscar por nome ou email…"
class="mp-search__input"
/>
</div>
<!-- View toggle (lista/grade) persiste em localStorage -->
<div class="mp-view-toggle" role="group" aria-label="Visualização">
<button
class="mp-view-toggle__btn"
:class="{ 'is-active': viewMode === 'list' }"
v-tooltip.bottom="'Lista'"
aria-label="Lista"
@click="setViewMode('list')"
>
<i class="pi pi-list" />
</button>
<button
class="mp-view-toggle__btn"
:class="{ 'is-active': viewMode === 'grid' }"
v-tooltip.bottom="'Grade'"
aria-label="Grade"
@click="setViewMode('grid')"
>
<i class="pi pi-th-large" />
</button>
</div>
<button class="mp-btn mp-btn--primary" @click="openCreatePopover($event)">
<i class="pi pi-plus text-xs" />
<span>Novo paciente</span>
</button>
</div>
<div class="mp-list__body">
<!-- Skeleton enquanto carrega pela 1ª vez (sem dados ainda).
Pattern do blueprint §9 usa computed pra não duplicar
a condição em vários v-ifs. -->
<template v-if="pacientesCarregandoInicial">
<div v-for="i in 6" :key="`mpsk-${i}`" class="mp-card mp-card--skeleton" aria-busy="true">
<span class="mp-card__avatar melissa-skeleton melissa-skeleton--avatar" />
<div class="mp-card__main" style="display:flex; flex-direction:column; gap:6px;">
<span class="melissa-skeleton melissa-skeleton--text" :style="{ width: `${55 + (i * 7) % 30}%` }" />
<span class="melissa-skeleton melissa-skeleton--text" :style="{ width: `${30 + (i * 11) % 25}%` }" />
</div>
</div>
</template>
<div v-else-if="pacientesOrdenados.length === 0" class="mp-empty-state">
<i class="pi pi-users mp-empty-state__icon" />
<div class="mp-empty-state__title">Nenhum paciente encontrado</div>
<div class="mp-empty-state__hint">
<template v-if="busca || grupoFiltroId || tagFiltroId || statusFiltro !== 'ativos'">
Ajuste os filtros ou limpe a busca pra ver mais resultados.
</template>
<template v-else>
Comece cadastrando seu primeiro paciente.
</template>
</div>
<div class="mp-empty-state__actions">
<button
v-if="busca || grupoFiltroId || tagFiltroId || statusFiltro !== 'ativos'"
class="mp-btn mp-btn--primary"
@click="limparFiltros"
>
<i class="pi pi-filter-slash text-xs" />
Limpar busca
</button>
<button class="mp-btn mp-btn--primary" @click="openCreatePopover($event)">
<i class="pi pi-plus text-xs" />
Novo paciente
</button>
</div>
</div>
<button
v-for="p in (pacientesCarregandoInicial || viewMode !== 'list' ? [] : pacientesPaginados)"
:key="p.id"
class="mp-card"
:class="{
'is-active': pacienteSelecionadoId === p.id,
'is-novo': isPacienteNovo(p)
}"
@click="selecionarPaciente(p.id)"
@dblclick="abrirProntuario(p)"
>
<span class="mp-card__avatar">
<img v-if="p.avatar_url" :src="p.avatar_url" :alt="p.nome" />
<span v-else>{{ pacienteIniciais(p.nome) }}</span>
</span>
<div class="mp-card__main">
<div class="mp-card__name-row">
<span class="mp-card__name">{{ p.nome }}</span>
<span v-if="isPacienteNovo(p)" class="mp-card__novo">novo</span>
<span
v-if="p.status && p.status !== 'Ativo'"
class="mp-card__status"
>
{{ p.status }}
</span>
</div>
<!-- Tags inline (até 3) -->
<div
v-if="(tagsByPatient.get(p.id) || []).length > 0"
class="mp-card__tags"
>
<span
v-for="t in (tagsByPatient.get(p.id) || []).slice(0, 3)"
:key="t.id"
class="mp-card__tag"
:style="{ backgroundColor: (t.color || '#94a3b8') + '22', color: t.color || 'var(--m-text-muted)', borderColor: (t.color || '#94a3b8') + '50' }"
>
{{ t.nome || t.name }}
</span>
<span
v-if="(tagsByPatient.get(p.id) || []).length > 3"
class="mp-card__tag-more"
>
+{{ (tagsByPatient.get(p.id) || []).length - 3 }}
</span>
</div>
</div>
<!-- Email coluna própria (separada do main pra leitura
tabular). Trunca com ellipsis. -->
<div class="mp-card__email">
<i class="pi pi-envelope" />
<span class="mp-card__email-text">{{ p.email || '—' }}</span>
</div>
<!-- Telefone coluna própria, paralela ao email. -->
<div class="mp-card__phone">
<i class="pi pi-phone" />
<span class="mp-card__phone-text">{{ p.telefone ? fmtPhone(p.telefone) : '—' }}</span>
</div>
<div class="mp-card__last">
<div class="mp-card__last-label">Última</div>
<div class="mp-card__last-date">{{ fmtUltimaSessao(p.last_attended_at) }}</div>
</div>
<!-- Ações inline: 5 botões em desktop, 1 pencil que abre
popover em mobile. stopPropagation em todos pra não
disparar selecionarPaciente do botão pai. Mobile: a
coluna é sticky-right (fixada visualmente). -->
<div class="mp-card__actions" @click.stop>
<!-- Desktop: 5 ações inline -->
<button
class="mp-card__action mp-card__action--desktop"
v-tooltip.top="'Abrir prontuário'"
@click="abrirProntuario(p)"
>
<i class="pi pi-file" />
</button>
<button
class="mp-card__action mp-card__action--desktop"
v-tooltip.top="'Ver sessões'"
@click="verSessoes(p)"
>
<i class="pi pi-history" />
</button>
<button
class="mp-card__action mp-card__action--desktop"
v-tooltip.top="'Conversar (WhatsApp)'"
@click="abrirWhatsapp(p)"
>
<i class="pi pi-whatsapp" />
</button>
<button
class="mp-card__action mp-card__action--desktop"
v-tooltip.top="'Editar'"
@click="editarPaciente(p)"
>
<i class="pi pi-pencil" />
</button>
<!-- Arquivar / Restaurar toggle condicional baseado
em p.status. Arquivar quando ativo (vermelho),
Restaurar quando arquivado (neutro/primary). -->
<button
v-if="!isArquivado(p)"
class="mp-card__action mp-card__action--desktop mp-card__action--danger"
v-tooltip.top="'Arquivar'"
@click="confirmarRemover(p)"
>
<i class="pi pi-trash" />
</button>
<button
v-else
class="mp-card__action mp-card__action--desktop mp-card__action--restore"
v-tooltip.top="'Restaurar'"
@click="restaurarPaciente(p)"
>
<i class="pi pi-undo" />
</button>
<!-- Mobile: 1 pencil que abre popover com todas as ações -->
<button
class="mp-card__action mp-card__action--mobile"
aria-label="Mais ações"
@click="openActionsPop($event, p)"
>
<i class="pi pi-pencil" />
</button>
</div>
</button>
<!-- Grid view cards num CSS grid responsivo. Mesma fonte
de dados (pacientesPaginados) que a list view. -->
<div v-if="viewMode === 'grid' && !pacientesCarregandoInicial && pacientesOrdenados.length > 0" class="mp-grid">
<div
v-for="p in pacientesPaginados"
:key="p.id"
class="mp-grid__card"
:class="{
'is-active': pacienteSelecionadoId === p.id,
'is-novo': isPacienteNovo(p)
}"
role="button"
tabindex="0"
@click="selecionarPaciente(p.id)"
@dblclick="abrirProntuario(p)"
@keydown.enter.prevent="selecionarPaciente(p.id)"
@keydown.space.prevent="selecionarPaciente(p.id)"
>
<div class="mp-grid__top">
<span class="mp-card__avatar">
<img v-if="p.avatar_url" :src="p.avatar_url" :alt="p.nome" />
<span v-else>{{ pacienteIniciais(p.nome) }}</span>
</span>
<button
class="mp-card__action mp-card__action--mobile"
aria-label="Mais ações"
@click.stop="openActionsPop($event, p)"
>
<i class="pi pi-pencil" />
</button>
</div>
<div class="mp-grid__name-row">
<span class="mp-grid__name">{{ p.nome }}</span>
<span v-if="isPacienteNovo(p)" class="mp-card__novo">novo</span>
</div>
<div v-if="p.status && p.status !== 'Ativo'" class="mp-grid__status">
{{ p.status }}
</div>
<div class="mp-grid__meta">
<span><i class="pi pi-envelope" /> {{ p.email || '—' }}</span>
<span><i class="pi pi-phone" /> {{ p.telefone ? fmtPhone(p.telefone) : '—' }}</span>
</div>
<div
v-if="(tagsByPatient.get(p.id) || []).length > 0"
class="mp-card__tags"
>
<span
v-for="t in (tagsByPatient.get(p.id) || []).slice(0, 3)"
:key="t.id"
class="mp-card__tag"
:style="{ backgroundColor: (t.color || '#94a3b8') + '22', color: t.color || 'var(--m-text-muted)', borderColor: (t.color || '#94a3b8') + '50' }"
>
{{ t.nome || t.name }}
</span>
<span
v-if="(tagsByPatient.get(p.id) || []).length > 3"
class="mp-card__tag-more"
>
+{{ (tagsByPatient.get(p.id) || []).length - 3 }}
</span>
</div>
<div class="mp-grid__last">
<i class="pi pi-clock" />
<span>Última: {{ fmtUltimaSessao(p.last_attended_at) }}</span>
</div>
</div>
</div>
</div>
<Paginator
v-if="!pacientesCarregandoInicial && pacientesOrdenados.length > 0"
class="mp-paginator"
:rows="rowsMP"
:totalRecords="pacientesOrdenados.length"
:first="(pageMP - 1) * rowsMP"
:rowsPerPageOptions="PAGE_SIZE_OPTIONS"
template="CurrentPageReport FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
currentPageReportTemplate="{first}{last} de {totalRecords}"
@page="onPageMPChange"
/>
</div>
<!-- COL 3: Quick view
Mesma lógica da COL 1 Teleport pro drawer em mobile. -->
<Teleport to="#mp-mobile-drawer-target" :disabled="!isMobile">
<aside class="mp-quick">
<!-- KPIs sempre visíveis no topo -->
<div class="mp-w">
<div class="mp-w__head">
<span class="mp-w__title">
<i class="pi pi-chart-bar" /> Visão geral
</span>
</div>
<div class="mp-stats">
<div class="mp-stat">
<div class="mp-stat__val">{{ kpis.total }}</div>
<div class="mp-stat__lbl">Total</div>
</div>
<div class="mp-stat">
<div class="mp-stat__val">{{ kpis.novos }}</div>
<div class="mp-stat__lbl">Novos<br><span class="text-[0.6rem] opacity-60">7 dias</span></div>
</div>
<div class="mp-stat">
<div class="mp-stat__val">{{ kpis.recentes }}</div>
<div class="mp-stat__lbl">Atendidos<br><span class="text-[0.6rem] opacity-60">14 dias</span></div>
</div>
</div>
<div v-if="kpis.aniversariantesMes > 0" class="mp-aniv">
<i class="pi pi-gift" />
<span>{{ kpis.aniversariantesMes }} aniversariante{{ kpis.aniversariantesMes > 1 ? 's' : '' }} este mês</span>
</div>
</div>
<!-- Card: Paciente selecionado (detalhes + ações) ou estado vazio -->
<div class="mp-w mp-w--detail">
<div v-if="pacienteSelecionado" class="mp-detail">
<button class="mp-detail__close" v-tooltip.left="'Limpar seleção'" @click="limparSelecao">
<i class="pi pi-times text-xs" />
</button>
<div class="mp-detail__avatar">
<img v-if="pacienteSelecionado.avatar_url" :src="pacienteSelecionado.avatar_url" :alt="pacienteSelecionado.nome" />
<span v-else>{{ pacienteIniciais(pacienteSelecionado.nome) }}</span>
</div>
<div class="mp-detail__name">{{ pacienteSelecionado.nome }}</div>
<div class="mp-detail__status">
<i
class="pi pi-circle-fill text-[0.5rem]"
:class="pacienteSelecionado.status === 'Ativo' ? 'text-emerald-400' : 'text-white/40'"
/>
{{ pacienteSelecionado.status || 'Ativo' }}
<span v-if="aniversarioEsteMes(pacienteSelecionado.data_nascimento)" class="mp-detail__aniv-badge">
<i class="pi pi-gift text-[0.6rem]" /> aniversário
</span>
</div>
<div class="mp-detail__rows">
<div v-if="pacienteSelecionado.email" class="mp-detail__row">
<i class="pi pi-envelope" />
<span class="truncate">{{ pacienteSelecionado.email }}</span>
</div>
<div v-if="pacienteSelecionado.telefone" class="mp-detail__row">
<i class="pi pi-phone" />
<span>{{ fmtPhone(pacienteSelecionado.telefone) }}</span>
</div>
<div class="mp-detail__row">
<i class="pi pi-calendar" />
<span>Última: {{ fmtUltimaSessao(pacienteSelecionado.last_attended_at) }}</span>
</div>
<div class="mp-detail__row">
<i class="pi pi-history" />
<span>{{ selectedSessionCount }} {{ selectedSessionCount === 1 ? 'sessão' : 'sessões' }}</span>
</div>
</div>
<!-- Grupos + tags do paciente -->
<div v-if="selectedGroups.length > 0 || selectedTags.length > 0" class="mp-detail__chips">
<span
v-for="g in selectedGroups"
:key="'g' + g.id"
class="mp-detail__chip"
:style="{ borderLeftColor: g.color || 'var(--m-text-muted)' }"
>
<span class="mp-chip__dot" :style="{ backgroundColor: g.color || 'var(--m-text-muted)' }" />
{{ g.nome || g.name }}
</span>
<span
v-for="t in selectedTags"
:key="'t' + t.id"
class="mp-detail__chip mp-detail__chip--tag"
:style="{ backgroundColor: (t.color || '#94a3b8') + '20', borderColor: (t.color || '#94a3b8') + '60', color: t.color || 'var(--m-text)' }"
>
{{ t.nome || t.name }}
</span>
</div>
<div class="mp-detail__actions">
<button
class="mp-btn mp-btn--primary mp-btn--block"
@click="abrirProntuario(pacienteSelecionado)"
>
<i class="pi pi-file" />
Abrir prontuário
</button>
<div class="grid grid-cols-2 gap-2 mt-2">
<button class="mp-btn mp-btn--ghost" @click="abrirWhatsapp(pacienteSelecionado)">
<i class="pi pi-whatsapp" />
Conversar
</button>
<button class="mp-btn mp-btn--ghost" @click="editarPaciente(pacienteSelecionado)">
<i class="pi pi-pencil" />
Editar
</button>
</div>
</div>
</div>
<div v-else class="mp-detail__empty">
<i class="pi pi-user text-3xl opacity-30 mb-3" />
<div class="text-sm">Selecione um paciente</div>
<div class="text-xs text-white/40 mt-1 text-center">
Clique em um card pra ver detalhes.<br>
Duplo-clique abre o prontuário.
</div>
</div>
</div>
</aside>
</Teleport>
</div>
<!-- Popover + dialogs de cadastro (montados fora do scroll). -->
<PatientCreatePopover
ref="createPopoverRef"
@quick-create="openQuickCreate"
@go-complete="goCreateFull"
/>
<PatientCadastroDialog
v-model="cadastroFullDialog"
:patient-id="editPatientId"
@created="onPatientCreated"
/>
<ComponentCadastroRapido
v-model="quickDialog"
title="Cadastro Rápido"
table-name="patients"
name-field="nome_completo"
email-field="email_principal"
phone-field="telefone"
@created="onPatientCreated"
/>
<!-- Dialog Prontuário :key força re-mount quando troca de paciente -->
<PatientProntuario
v-if="prontuarioPatient"
:key="prontuarioPatient?.id || 'none'"
v-model="prontuarioOpen"
:patient="prontuarioPatient"
@close="prontuarioOpen = false"
@edit="(id) => { prontuarioOpen = false; editPatientId = String(id); cadastroFullDialog = true; }"
/>
<!-- Dialog: Novo grupo (nome + cor) -->
<Dialog
v-model:visible="createGroupOpen"
modal
dismissable-mask
:style="{ width: '380px', maxWidth: '92vw' }"
header="Novo grupo"
>
<div class="flex flex-col gap-3">
<label class="text-xs text-[var(--text-color-secondary)]">
Nome
<InputText v-model="newGroupForm.name" placeholder="Ex: Adolescentes, Casais…" class="w-full mt-1" autofocus @keydown.enter="persistGroup" />
</label>
<label class="text-xs text-[var(--text-color-secondary)]">
Cor
<input v-model="newGroupForm.color" type="color" class="w-full h-9 mt-1 rounded-md border border-[var(--surface-border)] bg-transparent cursor-pointer" />
</label>
<div v-if="createGroupError" class="text-xs text-red-400">{{ createGroupError }}</div>
</div>
<template #footer>
<Button label="Cancelar" text @click="createGroupOpen = false" />
<Button :label="createGroupSaving ? 'Salvando…' : 'Criar'" :loading="createGroupSaving" :disabled="!newGroupForm.name.trim() || createGroupSaving" @click="persistGroup" />
</template>
</Dialog>
<!-- Dialog: Nova tag (nome + cor) -->
<Dialog
v-model:visible="createTagOpen"
modal
dismissable-mask
:style="{ width: '380px', maxWidth: '92vw' }"
header="Nova tag"
>
<div class="flex flex-col gap-3">
<label class="text-xs text-[var(--text-color-secondary)]">
Nome
<InputText v-model="newTagForm.name" placeholder="Ex: TDAH, Ansiedade, VIP…" class="w-full mt-1" autofocus @keydown.enter="persistTag" />
</label>
<label class="text-xs text-[var(--text-color-secondary)]">
Cor
<input v-model="newTagForm.color" type="color" class="w-full h-9 mt-1 rounded-md border border-[var(--surface-border)] bg-transparent cursor-pointer" />
</label>
<div v-if="createTagError" class="text-xs text-red-400">{{ createTagError }}</div>
</div>
<template #footer>
<Button label="Cancelar" text @click="createTagOpen = false" />
<Button :label="createTagSaving ? 'Salvando…' : 'Criar'" :loading="createTagSaving" :disabled="!newTagForm.name.trim() || createTagSaving" @click="persistTag" />
</template>
</Dialog>
<!-- Dialog de sessões do paciente lista os últimos 50 eventos -->
<Dialog
v-model:visible="sessoesDialogOpen"
modal
dismissable-mask
:style="{ width: '560px', maxWidth: '92vw' }"
:header="sessoesPaciente ? `Sessões — ${sessoesPaciente.nome}` : 'Sessões'"
>
<div v-if="sessoesLoading" class="py-10 text-center text-[var(--text-color-secondary)]">
<i class="pi pi-spin pi-spinner text-xl" />
<div class="text-sm mt-2">Carregando sessões</div>
</div>
<div v-else-if="sessoesLista.length === 0" class="py-10 text-center text-[var(--text-color-secondary)]">
<i class="pi pi-calendar-times text-2xl opacity-40" />
<div class="text-sm mt-2">Nenhuma sessão registrada.</div>
</div>
<div v-else class="flex flex-col gap-1.5 max-h-[60vh] overflow-y-auto">
<div
v-for="s in sessoesLista"
:key="s.id"
class="flex items-center gap-3 px-3 py-2 rounded-lg border border-[var(--surface-border)] bg-[var(--surface-card)]"
>
<div class="text-xs font-mono text-[var(--text-color)] tabular-nums w-[120px] shrink-0">
{{ fmtSessaoData(s.inicio_em) }}
</div>
<div class="flex-1 min-w-0 text-sm">
<div class="font-medium truncate">
{{ s.titulo || (s.tipo === 'sessao' ? 'Atendimento' : (s.tipo || 'Sessão')) }}
</div>
<div v-if="s.modalidade" class="text-xs text-[var(--text-color-secondary)]">
{{ s.modalidade }}
</div>
</div>
<span class="text-xs font-medium shrink-0" :class="sessaoStatusColor(s.status)">
{{ sessaoStatusLabel(s.status) }}
</span>
</div>
</div>
<template #footer>
<div class="text-[0.7rem] text-[var(--text-color-secondary)]">
Mostrando últimas {{ sessoesLista.length }} sessões.
</div>
</template>
</Dialog>
<!-- Popover de ações (mobile) disparado pelo pencil único do
card. Reusa as mesmas funções dos botões inline desktop. -->
<Popover ref="actionsPopRef" class="mp-actions-popover">
<div v-if="actionsTarget" class="mp-actions-menu">
<button class="mp-actions-menu__item" @click="actionsRun(abrirProntuario)">
<i class="pi pi-file" />
<span>Abrir prontuário</span>
</button>
<button class="mp-actions-menu__item" @click="actionsRun(verSessoes)">
<i class="pi pi-history" />
<span>Ver sessões</span>
</button>
<button class="mp-actions-menu__item" @click="actionsRun(abrirWhatsapp)">
<i class="pi pi-whatsapp" />
<span>Conversar (WhatsApp)</span>
</button>
<button class="mp-actions-menu__item" @click="actionsRun(editarPaciente)">
<i class="pi pi-pencil" />
<span>Editar</span>
</button>
<div class="mp-actions-menu__divider" />
<!-- Arquivar / Restaurar toggle baseado em status do paciente
atual no actionsTarget. Substitui um pelo outro pra evitar
ações redundantes (arquivar paciente arquivado é no-op). -->
<button
v-if="!isArquivado(actionsTarget)"
class="mp-actions-menu__item is-danger"
@click="actionsRun(confirmarRemover)"
>
<i class="pi pi-trash" />
<span>Arquivar</span>
</button>
<button
v-else
class="mp-actions-menu__item is-restore"
@click="actionsRun(restaurarPaciente)"
>
<i class="pi pi-undo" />
<span>Restaurar</span>
</button>
</div>
</Popover>
</section>
</template>
<style scoped>
/* ─── Convenção "Melissa Page" — espelha .ma-page do MelissaAgenda ─ */
.mp-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: mp-page-enter 240ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
@keyframes mp-page-enter {
from { opacity: 0; transform: scale(0.985); }
to { opacity: 1; transform: scale(1); }
}
.mp-page__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--m-border);
flex-shrink: 0;
}
.mp-page__title {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
font-size: 1rem;
font-weight: 500;
}
.mp-page__title-icon {
color: var(--p-primary-color);
font-size: 1.05rem;
}
.mp-page__title > span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Subheader explicativo — padrão do melissa-table-page-blueprint.md § 9.
Faixa abaixo do page__head com bg sutil + ícone primary + texto muted.
Diferencia esta página de outras com layout idêntico. */
.mp-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;
}
.mp-subheader__icon {
color: var(--p-primary-color);
font-size: 0.92rem;
flex-shrink: 0;
margin-top: 1px;
}
.mp-subheader__text {
flex: 1;
min-width: 0;
}
.mp-subheader__text strong {
color: var(--m-text);
font-weight: 600;
}
.mp-page__actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.mp-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;
margin-left: 4px;
}
.mp-page__loading {
color: var(--m-text-muted);
margin-left: 4px;
}
.mp-close {
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;
transition: background-color 140ms ease;
}
.mp-close:hover { background: var(--m-bg-soft-hover); }
/* Botão "Ir pra Agenda" no header — icon-only 32×32 (blueprint §11) */
.mp-header-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, border-color 140ms ease, color 140ms ease;
}
.mp-header-btn:hover {
background: var(--m-accent-soft);
border-color: var(--m-accent-strong);
color: var(--m-text);
}
.mp-header-btn > i { font-size: 0.85rem; }
/* Menu button (mobile only, esquerda do header) — primary filled,
abre o drawer com filtros + quickview. Aparece só em <lg. */
.mp-menu-btn {
display: none; /* show via @media (max-width: 1023px) */
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;
}
.mp-menu-btn:hover {
background: color-mix(in srgb, var(--m-accent) 88%, white);
transform: translateY(-1px);
}
.mp-menu-btn:active { transform: translateY(0); }
.mp-menu-btn > i { font-size: 0.85rem; }
.mp-body {
flex: 1;
display: flex;
min-height: 0;
}
/* ═══ COL 1: Filtros ═════════════════════════════════════════
Layout flex column com 2 zonas: __scroll (cards, scrollável) e
__footer (Limpar filtros, fixo no bottom). overflow: hidden no
container externo evita que o scroll vaze pra fora. */
.mp-side {
width: 240px;
flex-shrink: 0;
display: flex;
flex-direction: column;
border-right: 1px solid var(--m-border);
background: var(--m-bg-soft);
overflow: hidden;
}
.mp-side__scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
padding: 14px;
}
.mp-side__scroll::-webkit-scrollbar { width: 5px; }
.mp-side__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.mp-side__head {
display: flex;
align-items: baseline;
justify-content: space-between;
padding: 6px 16px;
}
.mp-side__title {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--m-text-muted);
font-weight: 600;
}
/* Cluster de ações (clear + add) à direita do título da seção */
.mp-side__head-actions {
display: flex;
align-items: center;
gap: 4px;
}
.mp-side__clear,
.mp-side__add,
.mp-side__cog {
width: 18px; height: 18px;
display: grid; place-items: center;
background: transparent;
border: 1px solid var(--m-border);
border-radius: 4px;
color: var(--m-text-muted);
cursor: pointer;
font-family: inherit;
transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease;
}
/* Clear (X) — vermelho pra indicar ação destrutiva (limpar filtro). */
.mp-side__clear {
color: rgb(220, 38, 38);
border-color: color-mix(in srgb, rgb(220, 38, 38) 30%, var(--m-border));
}
.mp-side__clear:hover {
background: rgba(220, 38, 38, 0.10);
border-color: rgba(220, 38, 38, 0.55);
color: rgb(220, 38, 38);
}
.mp-side__add:hover {
background: var(--m-accent-soft);
border-color: color-mix(in srgb, var(--m-accent) 50%, var(--m-border));
color: var(--m-accent);
}
/* Cog — sem borda (ghost icon), hover primary-tinted. */
.mp-side__cog {
border-color: transparent;
}
.mp-side__cog:hover {
background: color-mix(in srgb, var(--p-primary-color) 10%, transparent);
color: var(--p-primary-color);
}
.mp-side__divider {
height: 1px;
background: var(--m-border);
margin: 12px 12px;
}
.mp-side__placeholder {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
color: var(--m-text-faint);
font-size: 0.74rem;
font-style: italic;
}
/* Botão "+ Grupo" / "+ Tag" abaixo da lista — borda tracejada, vai pra
primary no hover. Move o "Adicionar" pra área dedicada (não compete
com o título no header como o antigo .mp-side__add fazia). */
.mp-side__add-btn {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 10px;
background: transparent;
border: 1.5px dashed var(--m-border-strong);
color: var(--m-text-muted);
border-radius: 8px;
cursor: pointer;
font-family: inherit;
font-size: 0.78rem;
font-weight: 500;
transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease;
}
.mp-side__add-btn:hover {
background: color-mix(in srgb, var(--p-primary-color) 6%, transparent);
border-color: var(--p-primary-color);
color: var(--p-primary-color);
}
.mp-side__add-btn > i { font-size: 0.7rem; }
/* Footer fixo no bottom da sidebar (fora do scroll dos cards).
Bg matching o da sidebar pra "ler" como continuação natural;
border-top marca a separação com a área scrollável acima. */
.mp-side__footer {
flex-shrink: 0;
padding: 12px;
background: var(--m-bg-soft);
border-top: 1px solid var(--m-border);
}
/* Transition do footer "Limpar filtros" — fade + collapse vertical
pra entrada/saída suave quando o user ativa/zera o último filtro. */
.mp-clear-enter-active,
.mp-clear-leave-active {
transition: opacity 220ms ease, transform 220ms ease, max-height 240ms ease;
overflow: hidden;
}
.mp-clear-enter-from,
.mp-clear-leave-to {
opacity: 0;
transform: translateY(6px);
max-height: 0;
}
.mp-clear-enter-to,
.mp-clear-leave-from {
opacity: 1;
transform: translateY(0);
max-height: 80px;
}
.mp-status-list {
display: flex;
flex-direction: column;
gap: 2px;
padding: 0;
}
/* Status pills — coloridos por status (padrão do blueprint § 7).
Cores Tailwind 600 (fortes pra ler em ambos os modos). 3 níveis:
default tint 5% + hover 10% + active 16% + ring 35%.
- Todos = neutral (sem cor — abrange tudo)
- Ativos = 🟢 verde (em tratamento)
- Inativos = 🟡 amber (pausa temporária, atenção)
- Arquivados = 🔴 vermelho (fora do ativo, finalizado) */
.mp-status-pill {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
background: transparent;
border: 1px solid transparent;
color: var(--m-text);
border-radius: 8px;
cursor: pointer;
font-size: 0.82rem;
font-family: inherit;
transition: background-color 140ms ease, border-color 140ms ease, box-shadow 140ms ease;
}
.mp-status-pill > i { font-size: 0.78rem; width: 14px; text-align: center; }
/* Todos — neutral (sem cor) */
.mp-status-pill.is-todos:hover { background: var(--m-bg-soft-hover); }
.mp-status-pill.is-todos.is-active {
background: var(--m-accent-soft);
border-color: var(--m-accent-strong);
}
/* Ativos — verde */
.mp-status-pill.is-ativos {
background: rgba(22, 163, 74, 0.05);
border-color: rgba(22, 163, 74, 0.18);
}
.mp-status-pill.is-ativos > i { color: rgb(22, 163, 74); }
.mp-status-pill.is-ativos:hover {
background: rgba(22, 163, 74, 0.10);
border-color: rgba(22, 163, 74, 0.30);
}
.mp-status-pill.is-ativos.is-active {
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);
}
/* Inativos — amber (warn, paciente pausado) */
.mp-status-pill.is-inativos {
background: rgba(217, 119, 6, 0.05);
border-color: rgba(217, 119, 6, 0.18);
}
.mp-status-pill.is-inativos > i { color: rgb(217, 119, 6); }
.mp-status-pill.is-inativos:hover {
background: rgba(217, 119, 6, 0.10);
border-color: rgba(217, 119, 6, 0.30);
}
.mp-status-pill.is-inativos.is-active {
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);
}
/* Arquivados — vermelho (fora do ativo) */
.mp-status-pill.is-arquivados {
background: rgba(220, 38, 38, 0.05);
border-color: rgba(220, 38, 38, 0.18);
}
.mp-status-pill.is-arquivados > i { color: rgb(220, 38, 38); }
.mp-status-pill.is-arquivados:hover {
background: rgba(220, 38, 38, 0.10);
border-color: rgba(220, 38, 38, 0.30);
}
.mp-status-pill.is-arquivados.is-active {
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);
}
.mp-status-pill__count {
font-size: 0.7rem;
color: var(--m-text-muted);
background: var(--m-bg-medium);
padding: 1px 7px;
border-radius: 999px;
min-width: 22px;
text-align: center;
}
.mp-status-pill.is-active .mp-status-pill__count {
background: var(--m-accent);
color: var(--p-primary-contrast-color, white);
}
.mp-chip-list {
display: flex;
flex-direction: column;
gap: 3px;
padding: 0;
}
.mp-chip {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px 6px 8px;
background: transparent;
border: 1px solid transparent;
border-left: 3px solid transparent;
color: var(--m-text);
border-radius: 7px;
cursor: pointer;
font-size: 0.78rem;
font-family: inherit;
text-align: left;
transition: background-color 140ms ease, border-color 140ms ease;
}
.mp-chip:hover { background: var(--m-bg-soft-hover); }
.mp-chip.is-active {
background: var(--m-accent-soft);
border-color: var(--m-accent-strong);
border-left-color: var(--m-accent);
}
.mp-chip__dot {
width: 8px; height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.mp-chip__name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Badge de contagem [N] — paridade com .mp-status-pill__count */
.mp-chip__count {
font-size: 0.7rem;
color: var(--m-text-muted);
background: var(--m-bg-medium);
padding: 1px 7px;
border-radius: 999px;
min-width: 22px;
text-align: center;
flex-shrink: 0;
}
.mp-chip.is-active .mp-chip__count {
background: var(--m-accent);
color: var(--p-primary-contrast-color, white);
}
/* ═══ COL 2: Lista central ═══════════════════════════════════ */
.mp-list {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
/* min-height: 0 é CRÍTICO pra que .mp-list__body (flex:1 + overflow-y:auto)
constrinja altura e scrolle de verdade — sem isso, o body cresce
indefinidamente e o scroll nunca dispara, especialmente em mobile
onde .mp-body vira flex-direction:column. */
min-height: 0;
}
.mp-list__toolbar {
display: flex;
gap: 10px;
padding: 14px 16px;
border-bottom: 1px solid var(--m-border);
}
.mp-search {
position: relative;
flex: 1;
}
.mp-search__icon {
position: absolute;
left: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--m-text-muted);
font-size: 0.85rem;
pointer-events: none;
}
.mp-search__input {
width: 100%;
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
color: var(--m-text);
padding: 9px 12px 9px 34px;
border-radius: 9px;
font-size: 0.88rem;
font-family: inherit;
outline: none;
transition: background-color 140ms ease, border-color 140ms ease;
}
.mp-search__input::placeholder { color: var(--m-text-faint); }
.mp-search__input:focus {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mp-list__body {
flex: 1;
overflow-y: auto;
padding: 8px 12px 16px;
display: flex;
flex-direction: column;
gap: 4px;
scrollbar-width: thin;
scrollbar-color: var(--m-border-strong) transparent;
}
.mp-list__body::-webkit-scrollbar { width: 6px; }
.mp-list__body::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
/* Paginator da lista — fixo no rodapé da coluna 2, com seletor de tamanho
de página. Sobrescrevo o visual default do PrimeVue pra alinhar com a
estética Melissa (transparente, ícones com border do tema). */
.mp-paginator.p-paginator {
background: transparent;
border: none;
border-top: 1px solid var(--m-border);
padding: 8px 12px;
justify-content: space-between;
flex-wrap: wrap;
gap: 6px;
}
.mp-paginator.p-paginator .p-paginator-current {
color: var(--m-text-muted);
font-size: 0.78rem;
background: transparent;
border: none;
padding: 0 4px;
height: 30px;
min-width: auto;
}
.mp-paginator.p-paginator .p-paginator-first,
.mp-paginator.p-paginator .p-paginator-prev,
.mp-paginator.p-paginator .p-paginator-next,
.mp-paginator.p-paginator .p-paginator-last,
.mp-paginator.p-paginator .p-paginator-page {
min-width: 30px;
height: 30px;
color: var(--m-text);
background: transparent;
border: 1px solid var(--m-border);
border-radius: 8px;
font-size: 0.8rem;
transition: background-color 140ms ease, border-color 140ms ease;
}
.mp-paginator.p-paginator .p-paginator-page.p-paginator-page-selected {
background: var(--m-accent-soft);
border-color: var(--m-accent-strong);
color: var(--m-accent);
}
.mp-paginator.p-paginator .p-paginator-first:not(.p-disabled):hover,
.mp-paginator.p-paginator .p-paginator-prev:not(.p-disabled):hover,
.mp-paginator.p-paginator .p-paginator-next:not(.p-disabled):hover,
.mp-paginator.p-paginator .p-paginator-last:not(.p-disabled):hover,
.mp-paginator.p-paginator .p-paginator-page:not(.p-paginator-page-selected):hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mp-paginator.p-paginator .p-paginator-first.p-disabled,
.mp-paginator.p-paginator .p-paginator-prev.p-disabled,
.mp-paginator.p-paginator .p-paginator-next.p-disabled,
.mp-paginator.p-paginator .p-paginator-last.p-disabled {
opacity: 0.35;
}
/* Dropdown de "rows per page" — combina com inputs do tema */
.mp-paginator.p-paginator .p-select {
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 8px;
height: 30px;
font-size: 0.78rem;
color: var(--m-text);
}
.mp-paginator.p-paginator .p-select:hover {
border-color: var(--m-border-strong);
}
.mp-paginator.p-paginator .p-select-label {
padding: 0 8px;
color: var(--m-text);
font-size: 0.78rem;
}
/* Empty state — borda tracejada, ícone grande centralizado, textos confortáveis
e CTAs primários (Limpar busca aparece só quando há filtro ativo). */
.mp-empty-state {
margin: 24px 8px;
padding: 56px 28px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
color: var(--m-text-muted);
border: 2px dashed var(--m-border-strong);
border-radius: 12px; /* teto blueprint §12 */
background: color-mix(in srgb, var(--m-bg-soft) 40%, transparent);
}
.mp-empty-state__icon {
font-size: 3.5rem;
color: var(--m-text-muted);
opacity: 0.55;
margin-bottom: 18px;
}
.mp-empty-state__title {
font-size: 1.15rem;
font-weight: 500;
color: var(--m-text);
letter-spacing: -0.01em;
}
.mp-empty-state__hint {
font-size: 0.92rem;
color: var(--m-text-muted);
margin-top: 6px;
max-width: 360px;
line-height: 1.45;
}
.mp-empty-state__actions {
display: flex;
gap: 10px;
margin-top: 22px;
flex-wrap: wrap;
justify-content: center;
}
/* Skeleton — sem hover/cursor */
.mp-card--skeleton {
cursor: default;
pointer-events: none;
opacity: 0.95;
}
.mp-card--skeleton:hover { background: transparent; }
.mp-card {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: transparent;
border: 1px solid transparent;
border-radius: 10px;
color: var(--m-text);
text-align: left;
cursor: pointer;
font-family: inherit;
transition: background-color 140ms ease, border-color 140ms ease;
}
.mp-card:hover {
background: var(--m-bg-soft);
border-color: var(--m-border);
}
.mp-card.is-active {
background: var(--m-accent-soft);
border-color: var(--m-accent-strong);
}
.mp-card.is-novo {
background: color-mix(in srgb, var(--p-primary-color) 8%, transparent);
}
.mp-card.is-novo.is-active {
background: var(--m-accent-soft);
border-color: var(--m-accent);
}
.mp-card__avatar {
width: 40px; height: 40px;
border-radius: 50%;
background: var(--m-accent);
border: 1px solid var(--m-accent);
color: var(--p-primary-contrast-color, white);
display: grid;
place-items: center;
font-size: 0.85rem;
font-weight: 600;
flex-shrink: 0;
overflow: hidden;
}
.mp-card__avatar img {
width: 100%; height: 100%;
object-fit: cover;
}
.mp-card__main {
flex: 1;
min-width: 0;
}
.mp-card__name-row {
display: flex;
align-items: center;
gap: 8px;
}
.mp-card__name {
font-size: 0.92rem;
font-weight: 500;
color: var(--m-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mp-card__novo {
font-size: 0.55rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 2px 6px;
border-radius: 999px;
background: var(--m-accent);
color: var(--p-primary-contrast-color, white);
flex-shrink: 0;
line-height: 1.2;
}
.mp-card__status {
font-size: 0.6rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 2px 6px;
border-radius: 999px;
background: var(--m-bg-soft);
color: var(--m-text-muted);
border: 1px solid var(--m-border);
flex-shrink: 0;
line-height: 1.2;
}
.mp-card__meta {
display: flex;
gap: 12px;
margin-top: 2px;
font-size: 0.74rem;
color: var(--m-text-muted);
overflow: hidden;
}
.mp-card__meta-item {
display: inline-flex;
align-items: center;
gap: 4px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Email — coluna própria do card. Width fixa pra alinhar visualmente
entre rows. Trunca com ellipsis quando longo. Em mobile, esconde
(info migra pro detail panel). */
.mp-card__email {
display: flex;
align-items: center;
gap: 6px;
width: 220px;
min-width: 0;
flex-shrink: 1;
font-size: 0.78rem;
color: var(--m-text-muted);
}
.mp-card__email > i { font-size: 0.7rem; flex-shrink: 0; }
.mp-card__email-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
/* Telefone — coluna própria, width menor (formato fixo). */
.mp-card__phone {
display: flex;
align-items: center;
gap: 6px;
width: 140px;
flex-shrink: 0;
font-size: 0.78rem;
color: var(--m-text-muted);
}
.mp-card__phone > i { font-size: 0.7rem; flex-shrink: 0; }
.mp-card__phone-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.mp-card__tags {
display: flex;
gap: 4px;
margin-top: 5px;
flex-wrap: nowrap;
overflow: hidden;
}
.mp-card__tag {
font-size: 0.65rem;
padding: 1px 7px;
border-radius: 999px;
border: 1px solid transparent;
line-height: 1.4;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100px;
}
.mp-card__tag-more {
font-size: 0.65rem;
color: var(--m-text-faint);
line-height: 1.4;
align-self: center;
}
.mp-card__last {
flex-shrink: 0;
text-align: right;
padding-left: 8px;
border-left: 1px solid var(--m-border);
}
.mp-card__last-label {
font-size: 0.6rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--m-text-faint);
}
.mp-card__last-date {
font-size: 0.82rem;
font-weight: 500;
color: var(--m-text-muted);
font-variant-numeric: tabular-nums;
margin-top: 1px;
}
/* Ações inline do card — visíveis no hover do .mp-card e sempre no ativo.
Em mobile/touch (≤ 768px) ficam sempre visíveis. */
.mp-card__actions {
display: flex;
gap: 2px;
margin-left: 4px;
padding-left: 8px;
border-left: 1px solid var(--m-border);
opacity: 0;
transform: translateX(4px);
transition: opacity 160ms ease, transform 160ms ease;
flex-shrink: 0;
}
.mp-card:hover .mp-card__actions,
.mp-card.is-active .mp-card__actions,
.mp-card:focus-within .mp-card__actions {
opacity: 1;
transform: translateX(0);
}
.mp-card__action {
width: 28px;
height: 28px;
display: grid;
place-items: center;
background: transparent;
border: 1px solid transparent;
color: var(--m-text-muted);
border-radius: 7px;
cursor: pointer;
font-family: inherit;
transition: background-color 140ms ease, color 140ms ease, border-color 140ms ease;
}
.mp-card__action i { font-size: 0.78rem; }
.mp-card__action:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border);
color: var(--m-text);
}
.mp-card__action--danger:hover {
background: rgba(239, 68, 68, 0.14);
border-color: rgba(239, 68, 68, 0.4);
color: rgb(248, 113, 113);
}
/* Restaurar — primary-tinted (ação positiva, oposto de danger). */
.mp-card__action--restore {
color: var(--p-primary-color);
}
.mp-card__action--restore:hover {
background: color-mix(in srgb, var(--p-primary-color) 14%, transparent);
border-color: color-mix(in srgb, var(--p-primary-color) 40%, var(--m-border));
color: var(--p-primary-color);
}
/* Mobile-only action button — pencil único que abre o popover.
Sempre visível, com bg-medium pra ler bem com o sticky-right.
Esconde por default; mostra só em mobile via media query abaixo. */
.mp-card__action--mobile {
display: none;
background: var(--p-content-background);
border-color: color-mix(in srgb, var(--p-primary-color) 30%, var(--m-border));
color: var(--p-primary-color);
}
.mp-card__action--mobile:hover {
background: color-mix(in srgb, var(--p-primary-color) 12%, var(--p-content-background));
border-color: var(--p-primary-color);
color: var(--p-primary-color);
}
/* View toggle (lista/grade) — mesmo segmented control do MCR/MAR. */
.mp-view-toggle {
flex-shrink: 0;
display: inline-flex;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 10px;
padding: 2px;
gap: 2px;
}
.mp-view-toggle__btn {
width: 32px;
height: 32px;
display: grid;
place-items: center;
background: transparent;
border: none;
color: var(--m-text-muted);
border-radius: 8px;
cursor: pointer;
font-family: inherit;
transition: background-color 140ms ease, color 140ms ease;
}
.mp-view-toggle__btn:hover {
background: var(--m-bg-soft-hover);
color: var(--m-text);
}
.mp-view-toggle__btn.is-active {
background: var(--m-accent-soft);
color: var(--m-accent);
}
.mp-view-toggle__btn > i { font-size: 0.85rem; }
/* ═══ Grid view — cards num grid responsivo ═════════════════ */
.mp-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 10px;
padding: 4px 0;
}
.mp-grid__card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 10px;
color: var(--m-text);
text-align: left;
cursor: pointer;
font-family: inherit;
transition: background-color 140ms ease, border-color 140ms ease, transform 140ms ease;
}
.mp-grid__card:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
transform: translateY(-1px);
}
.mp-grid__card:focus-visible {
outline: 2px solid var(--p-primary-color);
outline-offset: 2px;
}
.mp-grid__card.is-active {
background: var(--m-accent-soft);
border-color: var(--m-accent-strong);
}
.mp-grid__card.is-novo {
background: color-mix(in srgb, var(--p-primary-color) 8%, var(--m-bg-soft));
}
.mp-grid__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
/* No grid card o pencil sempre visível (mesmo desktop), pra dar acesso
às ações sem precisar passar mouse — o card é mais compacto que o
da lista. */
.mp-grid__card .mp-card__action--mobile {
display: grid;
}
.mp-grid__name-row {
display: flex;
align-items: center;
gap: 6px;
}
.mp-grid__name {
font-size: 0.92rem;
font-weight: 600;
color: var(--m-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
}
.mp-grid__status {
font-size: 0.6rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 2px 6px;
border-radius: 999px;
background: var(--m-bg-medium);
color: var(--m-text-muted);
border: 1px solid var(--m-border);
align-self: flex-start;
}
.mp-grid__meta {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.74rem;
color: var(--m-text-muted);
min-width: 0;
}
.mp-grid__meta i { margin-right: 5px; font-size: 0.7rem; }
.mp-grid__meta span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mp-grid__last {
margin-top: auto;
padding-top: 6px;
border-top: 1px solid var(--m-border);
font-size: 0.7rem;
color: var(--m-text-faint);
display: inline-flex;
align-items: center;
gap: 4px;
}
.mp-grid__last i { font-size: 0.65rem; }
/* ═══ Popover de ações (mobile) ════════════════════════════ */
.mp-actions-popover :deep(.p-popover-content) {
padding: 6px;
}
.mp-actions-menu {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 200px;
}
.mp-actions-menu__item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
background: transparent;
border: 1px solid transparent;
color: var(--m-text);
border-radius: 8px;
cursor: pointer;
font-family: inherit;
font-size: 0.85rem;
text-align: left;
transition: background-color 140ms ease, color 140ms ease;
}
.mp-actions-menu__item > i {
font-size: 0.85rem;
color: var(--m-text-muted);
width: 16px;
text-align: center;
}
.mp-actions-menu__item:hover {
background: var(--m-bg-soft-hover);
}
.mp-actions-menu__item:hover > i { color: var(--p-primary-color); }
.mp-actions-menu__item.is-danger {
color: rgb(220, 38, 38);
}
.mp-actions-menu__item.is-danger > i { color: rgb(220, 38, 38); }
.mp-actions-menu__item.is-danger:hover {
background: rgba(220, 38, 38, 0.10);
}
/* Restore — primary, semanticamente positivo. */
.mp-actions-menu__item.is-restore {
color: var(--p-primary-color);
}
.mp-actions-menu__item.is-restore > i { color: var(--p-primary-color); }
.mp-actions-menu__item.is-restore:hover {
background: color-mix(in srgb, var(--p-primary-color) 10%, transparent);
}
.mp-actions-menu__divider {
height: 1px;
background: var(--m-border);
margin: 4px 6px;
}
@media (max-width: 768px) {
.mp-card__actions {
opacity: 1;
transform: none;
/* Sticky-right pra fixar visualmente a coluna de ação enquanto
o user scrolla horizontalmente o conteúdo do card. */
position: sticky;
right: 0;
background: var(--m-bg-medium);
padding-left: 6px;
}
/* Em mobile, esconde os 5 botões inline e mostra só o pencil. */
.mp-card__action--desktop { display: none; }
.mp-card__action--mobile { display: grid; }
/* Esconde email e phone columns em mobile (muito apertado). Email
fica só no detail panel e no popover do paciente. */
.mp-card__email,
.mp-card__phone { display: none; }
}
/* ═══ COL 3: Quick view ══════════════════════════════════════ */
.mp-quick {
width: 320px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 12px;
border-left: 1px solid var(--m-border);
background: var(--m-bg-soft);
padding: 14px;
overflow-y: auto;
}
/* Card-base — espelha .ma-w da MelissaAgenda. Usado nas duas colunas
laterais (Status/Grupos/Tags na col 1, Visão geral/Detalhes na col 3).
Surface --m-bg-medium pra destacar das colunas (que usam --m-bg-soft) e
diferente do dialog (também --m-bg-soft). */
.mp-w {
display: flex;
flex-direction: column;
gap: 10px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 12px;
padding: 12px;
}
/* Sombra sutil pros cards de ambas as colunas laterais (igual MCR/MAR).
Eleva visualmente os cards sobre os bgs das asides. */
.mp-side .mp-w,
.mp-quick .mp-w {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
.mp-w__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0;
}
.mp-w__title {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.74rem;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--m-text-muted);
font-weight: 600;
}
.mp-w__divider {
height: 1px;
background: var(--m-border);
margin: 16px 0 14px;
}
/* Variante pro card de detalhes do paciente: zera o gap padrão (.mp-detail
já tem espaçamento interno próprio entre avatar/nome/rows/ações). */
.mp-w--detail {
gap: 0;
}
.mp-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 6px;
}
.mp-stat {
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 10px;
padding: 10px 6px;
text-align: center;
}
.mp-stat__val {
font-size: 1.5rem;
font-weight: 200;
line-height: 1;
letter-spacing: -0.02em;
color: var(--m-text);
font-variant-numeric: tabular-nums;
}
.mp-stat__lbl {
font-size: 0.7rem;
color: var(--m-text-muted);
margin-top: 4px;
line-height: 1.15;
}
.mp-aniv {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
padding: 8px 10px;
background: color-mix(in srgb, #f59e0b 14%, var(--m-bg-medium));
border: 1px solid color-mix(in srgb, #f59e0b 32%, transparent);
border-radius: 8px;
font-size: 0.78rem;
color: var(--m-text);
}
.mp-aniv i {
color: #f59e0b;
}
/* Detail card */
.mp-detail {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
padding: 8px 4px;
}
.mp-detail__close {
position: absolute;
top: 0;
right: 0;
width: 26px; height: 26px;
display: grid; place-items: center;
background: transparent;
border: 1px solid transparent;
color: var(--m-text-muted);
border-radius: 7px;
cursor: pointer;
transition: background-color 140ms ease;
}
.mp-detail__close:hover {
background: var(--m-bg-soft-hover);
color: var(--m-text);
}
.mp-detail__avatar {
width: 72px; height: 72px;
border-radius: 50%;
background: var(--m-accent);
border: 2px solid var(--m-accent-strong);
color: var(--p-primary-contrast-color, white);
display: grid;
place-items: center;
font-size: 1.4rem;
font-weight: 600;
overflow: hidden;
margin-bottom: 10px;
}
.mp-detail__avatar img {
width: 100%; height: 100%;
object-fit: cover;
}
.mp-detail__name {
font-size: 1rem;
font-weight: 500;
color: var(--m-text);
}
.mp-detail__status {
display: inline-flex;
align-items: center;
gap: 6px;
margin-top: 4px;
font-size: 0.74rem;
color: var(--m-text-muted);
}
.mp-detail__aniv-badge {
display: inline-flex;
align-items: center;
gap: 3px;
margin-left: 6px;
padding: 2px 7px;
border-radius: 999px;
background: color-mix(in srgb, #f59e0b 18%, transparent);
border: 1px solid color-mix(in srgb, #f59e0b 40%, transparent);
color: #f59e0b;
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: 600;
}
.mp-detail__rows {
width: 100%;
margin-top: 14px;
display: flex;
flex-direction: column;
gap: 6px;
text-align: left;
}
.mp-detail__row {
display: flex;
align-items: center;
gap: 8px;
padding: 7px 10px;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 8px;
font-size: 0.78rem;
color: var(--m-text);
}
.mp-detail__row i {
color: var(--m-text-muted);
font-size: 0.78rem;
flex-shrink: 0;
}
.mp-detail__row span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mp-detail__chips {
width: 100%;
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 4px;
justify-content: center;
}
.mp-detail__chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 9px 3px 7px;
border-radius: 999px;
border: 1px solid var(--m-border);
border-left-width: 3px;
background: var(--m-bg-medium);
font-size: 0.7rem;
color: var(--m-text);
}
.mp-detail__chip--tag {
border-left-width: 1px;
}
.mp-detail__actions {
width: 100%;
margin-top: 14px;
}
.mp-detail__empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
color: var(--m-text-muted);
}
/* ─── Botões compartilhados ────────────────────────────────── */
.mp-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 14px;
border-radius: 9px;
font-size: 0.82rem;
font-weight: 500;
font-family: inherit;
cursor: pointer;
transition: background-color 140ms ease, border-color 140ms ease;
border: 1px solid transparent;
}
.mp-btn--block { width: 100%; }
.mp-btn--primary {
background: var(--m-accent);
border-color: var(--m-accent);
color: var(--p-primary-contrast-color, white);
}
.mp-btn--primary:hover {
background: color-mix(in srgb, var(--m-accent) 82%, white);
}
.mp-btn--ghost {
background: var(--m-bg-medium);
border-color: var(--m-border);
color: var(--m-text);
}
.mp-btn--ghost:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
/* ═══════════════════════════════════════════════════════════════
Drawer mobile (blueprint §6) — paridade total com .ma-mobile-drawer
═══════════════════════════════════════════════════════════════ */
.mp-mobile-drawer {
position: fixed;
top: 0;
left: 0;
height: 100vh;
height: 100dvh;
width: min(360px, 88vw);
z-index: 80; /* acima do ψ (70) — blueprint §4 */
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;
}
.mp-mobile-drawer.is-open {
transform: translateX(0);
}
.mp-mobile-drawer__scroll {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
/* Pattern espelha AppMenu/layout-sidebar: drawer = flex column, host
pass-through (overflow hidden), .mp-side é flex column com scroll
interno e __footer flex-shrink: 0 pinado no bottom.
.mp-quick fica fixo no topo (flex-shrink: 0). */
.mp-mobile-drawer__scroll .mp-quick {
width: 100%;
flex-shrink: 0;
height: auto;
overflow-y: auto;
overflow-x: hidden;
border-right: none;
border-left: none;
background: transparent;
padding: 12px;
max-height: 50%;
}
.mp-mobile-drawer__scroll .mp-side {
width: 100%;
flex: 1;
min-height: 0;
overflow: hidden;
border-right: none;
border-left: none;
background: transparent;
padding: 0;
display: flex;
flex-direction: column;
}
.mp-mobile-drawer__scroll .mp-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;
}
.mp-mobile-drawer__scroll .mp-side__scroll::-webkit-scrollbar { width: 5px; }
.mp-mobile-drawer__scroll .mp-side__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
.mp-mobile-drawer__scroll .mp-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%);
}
/* Reordena dentro do drawer: Visão geral (.mp-quick) vem PRIMEIRO,
filtros (.mp-side) depois. Em desktop o template mantém a ordem
natural (filtros à esquerda, quickview à direita) — order só
afeta o .mp-mobile-drawer__scroll que é flex column. */
.mp-mobile-drawer__scroll .mp-quick { order: 0; }
.mp-mobile-drawer__scroll .mp-side { order: 1; }
.mp-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; /* acima do ψ (70), abaixo do drawer (80) */
}
.mp-drawer-fade-enter-active,
.mp-drawer-fade-leave-active { transition: opacity 200ms ease; }
.mp-drawer-fade-enter-from,
.mp-drawer-fade-leave-to { opacity: 0; }
/* ═══════════════════════════════════════════════════════════════
Responsivo <lg (≤1023px) — "mobile" (blueprint §3)
───────────────────────────────────────────────────────────────
- .mp-side e .mp-quick somem do .mp-body (Teleport os move pro
.mp-mobile-drawer fora do layout)
- .mp-list vira fullwidth
- Botão "Menu" aparece à esquerda do header
- Título da página some (Menu já carrega o nome da seção)
═══════════════════════════════════════════════════════════════ */
@media (max-width: 1023px) {
.mp-body {
flex-direction: column;
}
.mp-list {
width: 100%;
}
.mp-page__title { display: none; }
.mp-menu-btn--mobile-only { display: inline-flex; }
}
</style>