02af119dc6
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>
3022 lines
109 KiB
Vue
3022 lines
109 KiB
Vue
<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 já 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>
|