Files
agenciapsilmno/src/layout/melissa/MelissaCadastrosRecebidos.vue
T
Leonardo f17e9ee786 F1b: 6 tabelas anon-facing ficam em public (decisao roteamento anon)
Fluxos anon identificam tenant por token/slug e nao resolvem o schema fisico.
Decisao (opcao C): manter em public com RLS por token. Volta a global:
patient_intake_requests, patient_invites, patient_invite_attempts,
document_share_links, agendador_configuracoes, agendador_solicitacoes.

- migration 20260613000001_f1b: remove as 6 do _tenant_template (template v2,
  78 tabelas). Smoke: clone gera 78, zero tabelas anon no schema, drop limpo
- frontend: 38 cadeias em 14 arquivos revertidas tenantDb().from() ->
  supabase.from() com tenant_id/owner_id restaurado (via comparacao com main)
- edge: convert-abandoned-intakes restaurada do main (SELECT global)
- save-intake-progress: ja usava public, sem mudanca
- doc F0 atualizado: 78 tenant + 59 global

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 09:09:46 -03:00

1901 lines
70 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
/*
* MelissaCadastrosRecebidos — Cadastros recebidos (intake requests externos).
* Segue blueprint melissa-page-blueprint.md.
*
* Layout 2-col:
* - COL 1 — Aside (~280px): stats por status (Novo/Convertido/Rejeitado)
* + filtros (status pill toggle) + busca
* - COL 2 — Lista de cards de cadastro (avatar + nome + telefone/email +
* status badge + tempo)
*
* Click num card → dialog com detalhes + ações (Rejeitar / Converter em paciente).
* Reaproveita a lógica de patient_intake_requests da página antiga.
*/
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
// Fase 2 (Graphify hotspot): convertToPatient duplicado em 2 pages — extração pro repository.
import { createPatient, markIntakeConverted } from '@/features/patients/services/patientsRepository';
import { brToISO, isoToBR } from '@/utils/dateBR';
// Dialog/Textarea/Button auto-imported via PrimeVueResolver
const emit = defineEmits(['close']);
const toast = useToast();
const confirm = useConfirm();
const tenantStore = useTenantStore();
// ── Breakpoints + drawer ───────────────────────────────────
const drawerOpen = ref(false);
const isMobile = ref(false);
let _mqMobile = null;
function _onMqMobileChange(e) {
isMobile.value = e.matches;
if (!e.matches) drawerOpen.value = false;
}
function toggleDrawer() { drawerOpen.value = !drawerOpen.value; }
function fecharDrawer() { drawerOpen.value = false; }
// ── Estado ─────────────────────────────────────────────────
const loading = ref(false);
const converting = ref(false);
const rows = ref([]);
const busca = ref('');
const statusFilter = ref('');
const carregandoInicial = computed(
() => loading.value && rows.value.length === 0
);
// ── Helpers de campos (espelhados da CadastrosRecebidosPage) ──
function pickField(obj, keys) {
for (const k of keys) {
const v = obj?.[k];
if (v !== undefined && v !== null && String(v).trim() !== '') return v;
}
return null;
}
const fNome = (i) => pickField(i, ['nome_completo', 'name']);
const fEmail = (i) => pickField(i, ['email_principal', 'email']);
const fEmailAlt = (i) => pickField(i, ['email_alternativo', 'email_alt']);
const fTel = (i) => pickField(i, ['telefone', 'phone']);
const fTelAlt = (i) => pickField(i, ['telefone_alternativo', 'phone_alt']);
const fNasc = (i) => pickField(i, ['data_nascimento', 'birth_date']);
const fGenero = (i) => pickField(i, ['genero', 'gender']);
const fProf = (i) => pickField(i, ['profissao', 'profession']);
const fCep = (i) => pickField(i, ['cep']);
const fEndereco = (i) => pickField(i, ['endereco', 'address_street']);
const fNumero = (i) => pickField(i, ['numero', 'address_number']);
const fBairro = (i) => pickField(i, ['bairro', 'address_neighborhood']);
const fCidade = (i) => pickField(i, ['cidade', 'address_city']);
const fEstado = (i) => pickField(i, ['estado', 'address_state']);
const fEstadoCivil = (i) => pickField(i, ['estado_civil', 'marital_status']);
const fNaturalidade = (i) => pickField(i, ['naturalidade', 'place_of_birth']);
const fEscolaridade = (i) => pickField(i, ['escolaridade', 'education_level']);
const fOndeConheceu = (i) => pickField(i, ['onde_nos_conheceu', 'lead_source']);
const fEncaminhado = (i) => pickField(i, ['encaminhado_por', 'referred_by']);
const fObs = (i) => pickField(i, ['observacoes', 'notes_short']);
const fComplemento = (i) => pickField(i, ['complemento', 'address_complement']);
const fPais = (i) => pickField(i, ['pais', 'country']) || 'Brasil';
const fEmailAlt2 = (i) => pickField(i, ['email_alternativo', 'email_alt']);
// ── Formatters ─────────────────────────────────────────────
function dash(v) {
const s = String(v ?? '').trim();
return s ? s : '—';
}
function onlyDigits(v) { return String(v ?? '').replace(/\D/g, ''); }
function fmtPhoneBR(v) {
const d = onlyDigits(v);
if (!d) return '—';
if (d.length === 11) return `(${d.slice(0,2)}) ${d.slice(2,7)}-${d.slice(7,11)}`;
if (d.length === 10) return `(${d.slice(0,2)}) ${d.slice(2,6)}-${d.slice(6,10)}`;
return d;
}
function fmtCPF(v) {
const d = onlyDigits(v);
if (!d || d.length !== 11) return d || '—';
return `${d.slice(0,3)}.${d.slice(3,6)}.${d.slice(6,9)}-${d.slice(9,11)}`;
}
function fmtBirth(v) {
if (!v) return '—';
const s = String(v).trim();
if (/^\d{2}-\d{2}-\d{4}$/.test(s)) return s;
if (/^\d{4}-\d{2}-\d{2}/.test(s)) return isoToBR(s.slice(0, 10)) || s;
return s;
}
function normalizeBirthToISO(v) {
if (!v) return null;
const s = String(v).trim();
if (!s) return null;
if (/^\d{2}-\d{2}-\d{4}$/.test(s)) return brToISO(s);
if (/^\d{4}-\d{2}-\d{2}/.test(s)) return s.slice(0, 10);
const d = new Date(s);
if (Number.isNaN(d.getTime())) return null;
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function fmtRelative(iso) {
if (!iso) return '—';
const d = new Date(iso);
const now = new Date();
const diff = Math.floor((now - d) / 1000);
if (diff < 60) return 'agora';
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
if (diff < 604800) return `${Math.floor(diff / 86400)}d`;
return d.toLocaleDateString('pt-BR');
}
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();
}
// ── Avatar URL ─────────────────────────────────────────────
const AVATAR_BUCKET = 'avatars';
function looksLikeUrl(s) { return /^https?:\/\//i.test(String(s || '')); }
function firstNonEmpty(...vals) {
for (const v of vals) {
const s = String(v ?? '').trim();
if (s) return s;
}
return '';
}
function avatarUrl(row) {
const p = row?.payload || row?.data || row?.form || null;
const direct = firstNonEmpty(row?.avatar_url, row?.foto_url, row?.photo_url, p?.avatar_url, p?.foto_url, p?.photo_url);
if (direct && looksLikeUrl(direct)) return direct;
const path = firstNonEmpty(row?.avatar_path, row?.photo_path, p?.avatar_path, p?.photo_path, direct);
if (!path) return null;
if (looksLikeUrl(path)) return path;
const { data } = supabase.storage.from(AVATAR_BUCKET).getPublicUrl(path);
return data?.publicUrl || null;
}
// ── Status helpers ─────────────────────────────────────────
function statusLabel(s) {
if (s === 'new') return 'Novo';
if (s === 'converted') return 'Convertido';
if (s === 'rejected') return 'Rejeitado';
return s || '—';
}
function statusClass(s) {
if (s === 'new') return 'is-new';
if (s === 'converted') return 'is-done';
if (s === 'rejected') return 'is-rejected';
return '';
}
// ── Stats ──────────────────────────────────────────────────
const stats = computed(() => {
const all = rows.value;
const n = all.filter((r) => r.status === 'new').length;
const c = all.filter((r) => r.status === 'converted').length;
const r = all.filter((r) => r.status === 'rejected').length;
return [
{ key: 'total', label: 'Total', value: all.length, cls: 'neutral' },
{ key: 'new', label: 'Novos', value: n, cls: n > 0 ? 'info' : 'neutral' },
{ key: 'converted', label: 'Convertidos', value: c, cls: 'ok' },
{ key: 'rejected', label: 'Rejeitados', value: r, cls: r > 0 ? 'danger' : 'neutral' }
];
});
const STATUS_FILTER_OPTIONS = [
{ key: 'new', label: 'Novos', icon: 'pi pi-bell' },
{ key: 'converted', label: 'Convertidos', icon: 'pi pi-check' },
{ key: 'rejected', label: 'Rejeitados', icon: 'pi pi-times' }
];
function toggleStatusFilter(s) {
statusFilter.value = statusFilter.value === s ? '' : s;
}
// ── Filtragem ──────────────────────────────────────────────
const filtered = computed(() => {
const term = String(busca.value || '').trim().toLowerCase();
let list = rows.value;
if (statusFilter.value) list = list.filter((r) => r.status === statusFilter.value);
if (!term) return list;
return list.filter((r) => {
const nome = String(fNome(r) || '').toLowerCase();
const email = String(fEmail(r) || '').toLowerCase();
const tel = String(fTel(r) || '').toLowerCase();
return nome.includes(term) || email.includes(term) || tel.includes(term);
});
});
// ── Paginação client-side (DataTable controla internamente) ──────
// `fetchIntakes` traz a lista completa de patient_intake_requests do
// tenant — o DataTable pagina internamente via prop `paginator`. A
// gente só precisa controlar `rows` (tamanho da página) e `first`
// (índice do primeiro item) pra reagir a filtros e mudanças de tamanho.
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
const rowsMCR = ref(10);
const firstMCR = ref(0);
function onPage(event) {
firstMCR.value = event.first;
rowsMCR.value = event.rows;
}
// Reset pra página 1 quando busca/status mudam.
watch([busca, statusFilter], () => { firstMCR.value = 0; });
// Click numa row → abre o dialog. selectionMode="single" é só pra
// marcar visualmente — não persistimos seleção.
function onRowClick(event) {
if (event?.data) openDetails(event.data);
}
function rowStatusClass(data) {
return statusClass(data?.status);
}
// ── View mode (list / grid) ────────────────────────────────
// Toggle no topo do main column. Persiste em localStorage pra manter
// a preferência do user entre sessões. List = DataTable; Grid = cards
// num CSS grid + Paginator standalone (compartilha rowsMCR/firstMCR).
const VIEW_MODE_KEY = 'mcr.viewMode.v1';
const viewMode = ref('list');
try {
const saved = localStorage.getItem(VIEW_MODE_KEY);
if (saved === 'list' || saved === 'grid') viewMode.value = saved;
} catch (_) { /* localStorage indisponível — mantém default */ }
function setViewMode(m) {
if (m !== 'list' && m !== 'grid') return;
viewMode.value = m;
try { localStorage.setItem(VIEW_MODE_KEY, m); } catch (_) { /* noop */ }
}
// Slice pra grid (DataTable já pagina internamente; grid precisa
// fatiar o `filtered` com base no firstMCR/rowsMCR compartilhados).
const pagedItems = computed(() => {
return filtered.value.slice(firstMCR.value, firstMCR.value + rowsMCR.value);
});
// ── Fetch ──────────────────────────────────────────────────
async function fetchIntakes() {
loading.value = true;
try {
const { data, error } = await supabase
.from('patient_intake_requests').select('*')
.order('created_at', { ascending: false });
if (error) throw error;
const weight = (s) => (s === 'new' ? 0 : s === 'converted' ? 1 : s === 'rejected' ? 2 : 9);
rows.value = (data || []).slice().sort((a, b) => {
const wa = weight(a.status), wb = weight(b.status);
if (wa !== wb) return wa - wb;
return new Date(b.created_at || 0).getTime() - new Date(a.created_at || 0).getTime();
});
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao carregar', detail: e?.message || String(e), life: 3500 });
} finally {
loading.value = false;
}
}
// ── Dialog detalhes ────────────────────────────────────────
const dlg = ref({ open: false, item: null, saving: false, reject_note: '' });
function openDetails(row) {
dlg.value.open = true;
dlg.value.item = row;
dlg.value.reject_note = row?.rejected_reason || '';
}
function closeDlg() {
dlg.value.open = false;
dlg.value.saving = false;
dlg.value.item = null;
dlg.value.reject_note = '';
}
const dlgAvatarUrl = computed(() => dlg.value.item ? avatarUrl(dlg.value.item) : null);
const dlgSections = computed(() => {
const i = dlg.value.item;
if (!i) return [];
const sec = (title, rs) => ({ title, rows: rs.filter((r) => r.value && r.value !== '—') });
return [
sec('Identificação', [
{ label: 'Email', value: dash(fEmail(i)) },
{ label: 'Email alt.', value: dash(fEmailAlt(i)) },
{ label: 'Telefone', value: fmtPhoneBR(fTel(i)) },
{ label: 'Tel. alt.', value: fmtPhoneBR(fTelAlt(i)) },
{ label: 'Nascimento', value: fmtBirth(fNasc(i)) },
{ label: 'Gênero', value: dash(fGenero(i)) },
{ label: 'Estado civil', value: dash(fEstadoCivil(i)) }
]),
sec('Documentos', [
{ label: 'CPF', value: fmtCPF(i.cpf) },
{ label: 'RG', value: dash(i.rg) }
]),
sec('Endereço', [
{ label: 'CEP', value: dash(fCep(i)) },
{ label: 'Endereço', value: [fEndereco(i), fNumero(i)].filter(Boolean).join(', ') || '—' },
{ label: 'Complemento', value: dash(fComplemento(i)) },
{ label: 'Bairro', value: dash(fBairro(i)) },
{ label: 'Cidade/UF', value: [fCidade(i), fEstado(i)].filter(Boolean).join('/') || '—' },
{ label: 'País', value: fPais(i) }
]),
sec('Profissional', [
{ label: 'Profissão', value: dash(fProf(i)) },
{ label: 'Escolaridade', value: dash(fEscolaridade(i)) },
{ label: 'Naturalidade', value: dash(fNaturalidade(i)) },
{ label: 'Onde conheceu', value: dash(fOndeConheceu(i)) },
{ label: 'Encaminhado por', value: dash(fEncaminhado(i)) }
]),
sec('Observações', [
{ label: 'Obs.', value: dash(fObs(i)) },
{ label: 'Notas internas', value: dash(i.notas_internas || i.notes) }
])
].filter((s) => s.rows.length > 0);
});
// ── Rejeitar ───────────────────────────────────────────────
async function markRejected() {
const item = dlg.value.item;
if (!item) return;
confirm.require({
message: 'Marcar este cadastro como rejeitado?',
header: 'Confirmar rejeição',
icon: 'pi pi-exclamation-triangle',
acceptLabel: 'Rejeitar',
rejectLabel: 'Cancelar',
acceptSeverity: 'danger',
accept: async () => {
dlg.value.saving = true;
try {
const reason = String(dlg.value.reject_note || '').trim() || null;
const { error } = await supabase.from('patient_intake_requests')
.update({ status: 'rejected', rejected_reason: reason, updated_at: new Date().toISOString() })
.eq('id', item.id);
if (error) throw error;
toast.add({ severity: 'success', summary: 'Rejeitado', life: 2200 });
await fetchIntakes();
const updated = rows.value.find((r) => r.id === item.id);
if (updated) openDetails(updated); else closeDlg();
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message, life: 4500 });
} finally {
dlg.value.saving = false;
}
}
});
}
// ── Converter ──────────────────────────────────────────────
async function getResponsibleMemberId(tenantId, userId) {
const { data, error } = await supabase.from('tenant_members')
.select('id').eq('tenant_id', tenantId).eq('user_id', userId)
.eq('status', 'active').maybeSingle();
if (error) throw error;
if (!data?.id) throw new Error('Membro responsável não encontrado.');
return data.id;
}
async function convertToPatient() {
const item = dlg.value?.item;
if (!item?.id || converting.value) return;
if (item.status === 'converted') {
toast.add({ severity: 'warn', summary: 'Já convertido', life: 2500 });
return;
}
converting.value = true;
try {
const { data: userData, error: userErr } = await supabase.auth.getUser();
if (userErr) throw userErr;
const ownerId = userData?.user?.id;
if (!ownerId) throw new Error('Sessão inválida.');
const tenantId = tenantStore.activeTenantId || tenantStore.tenantId || null;
if (!tenantId) throw new Error('Tenant não inicializado.');
const responsibleMemberId = await getResponsibleMemberId(tenantId, ownerId);
const cleanStr = (v) => { const s = String(v ?? '').trim(); return s ? s : null; };
const digitsOnly = (v) => { const d = String(v ?? '').replace(/\D/g, ''); return d ? d : null; };
const intakeAvatar = cleanStr(item.avatar_url) || cleanStr(item.foto_url) || cleanStr(item.photo_url) || null;
const patientPayload = {
tenant_id: tenantId,
responsible_member_id: responsibleMemberId,
owner_id: ownerId,
nome_completo: cleanStr(fNome(item)),
email_principal: cleanStr(fEmail(item))?.toLowerCase() || null,
email_alternativo: cleanStr(fEmailAlt2(item))?.toLowerCase() || null,
telefone: digitsOnly(fTel(item)),
telefone_alternativo: digitsOnly(fTelAlt(item)),
data_nascimento: normalizeBirthToISO(fNasc(item)),
naturalidade: cleanStr(fNaturalidade(item)),
genero: cleanStr(fGenero(item)),
estado_civil: cleanStr(fEstadoCivil(item)),
cpf: digitsOnly(item.cpf),
rg: cleanStr(item.rg),
pais: cleanStr(fPais(item)) || 'Brasil',
cep: digitsOnly(fCep(item)),
cidade: cleanStr(fCidade(item)),
estado: cleanStr(fEstado(item)) || 'SP',
endereco: cleanStr(fEndereco(item)),
numero: cleanStr(fNumero(item)),
bairro: cleanStr(fBairro(item)),
complemento: cleanStr(fComplemento(item)),
escolaridade: cleanStr(fEscolaridade(item)),
profissao: cleanStr(fProf(item)),
onde_nos_conheceu: cleanStr(fOndeConheceu(item)),
encaminhado_por: cleanStr(fEncaminhado(item)),
observacoes: cleanStr(fObs(item)),
notas_internas: cleanStr(item.notas_internas || item.notes),
avatar_url: intakeAvatar
};
Object.keys(patientPayload).forEach((k) => {
if (patientPayload[k] === undefined) delete patientPayload[k];
});
// Repository chamadas (Fase 2 — convertToPatient de-dup).
const created = await createPatient(patientPayload);
const patientId = created?.id;
if (!patientId) throw new Error('Falha ao obter ID do paciente.');
await markIntakeConverted(item.id, patientId);
toast.add({ severity: 'success', summary: 'Convertido em paciente', life: 2500 });
closeDlg();
await fetchIntakes();
} catch (e) {
toast.add({ severity: 'error', summary: 'Falha ao converter', detail: e?.message, life: 4500 });
} finally {
converting.value = false;
}
}
onMounted(async () => {
if (typeof window !== 'undefined' && window.matchMedia) {
_mqMobile = window.matchMedia('(max-width: 1023px)');
isMobile.value = _mqMobile.matches;
_mqMobile.addEventListener('change', _onMqMobileChange);
}
if (typeof tenantStore.loadSessionAndTenant === 'function') {
await tenantStore.loadSessionAndTenant();
}
await fetchIntakes();
});
onBeforeUnmount(() => {
if (_mqMobile) _mqMobile.removeEventListener('change', _onMqMobileChange);
});
</script>
<template>
<aside
class="mcr-mobile-drawer"
:class="{ 'is-open': drawerOpen }"
v-show="isMobile"
aria-label="Estatísticas e filtros"
>
<div id="mcr-mobile-drawer-target" class="mcr-mobile-drawer__scroll" />
</aside>
<Transition name="mcr-drawer-fade">
<div
v-if="isMobile && drawerOpen"
class="mcr-mobile-drawer__backdrop"
@click="fecharDrawer"
/>
</Transition>
<section class="mcr-page">
<header class="mcr-page__head">
<button
class="mcr-menu-btn mcr-menu-btn--mobile-only"
v-tooltip.bottom="'Estatísticas & filtros'"
@click="toggleDrawer"
>
<i class="pi pi-bars" />
<span>Menu Cadastros</span>
</button>
<div class="mcr-page__title">
<i class="pi pi-inbox mcr-page__title-icon" />
<span>Cadastros recebidos</span>
<span class="mcr-page__count">{{ filtered.length }}</span>
</div>
<div class="mcr-page__actions">
<button
class="mcr-head-btn"
v-tooltip.bottom="'Recarregar'"
:disabled="loading"
@click="fetchIntakes"
>
<i :class="loading ? 'pi pi-spin pi-spinner' : 'pi pi-refresh'" />
</button>
<button class="mcr-close" v-tooltip.bottom="'Voltar (Esc)'" @click="emit('close')">
<i class="pi pi-times" />
</button>
</div>
</header>
<!-- Subheader explicativo diferencia visualmente desta página
(vs. Agendamentos Recebidos, que tem layout idêntico). Padrão
do melissa-table-page-blueprint.md. -->
<div class="mcr-subheader">
<i class="pi pi-info-circle mcr-subheader__icon" />
<span class="mcr-subheader__text">
Cadastros completos enviados por pacientes via formulário
externo (link público). Revise os dados,
<strong>converta em paciente ativo</strong> com 1 clique ou
<strong>rejeite</strong> com motivo opcional.
</span>
</div>
<div class="mcr-body">
<Teleport to="#mcr-mobile-drawer-target" :disabled="!isMobile">
<aside class="mcr-side">
<!-- Stats -->
<div class="mcr-w mcr-w--side">
<div class="mcr-w__head">
<span class="mcr-w__title"><i class="pi pi-chart-bar" /> Estatísticas</span>
</div>
<div class="mcr-stats">
<template v-if="carregandoInicial">
<div v-for="i in 4" :key="`stsk-${i}`" class="mcr-stat" aria-busy="true">
<div class="mcr-stat__val melissa-skeleton melissa-skeleton--number" />
<div class="mcr-stat__lbl melissa-skeleton melissa-skeleton--text" style="width: 60%; margin-top: 6px;" />
</div>
</template>
<div
v-for="s in stats"
v-else
:key="s.key"
class="mcr-stat"
:class="`is-${s.cls}`"
>
<div class="mcr-stat__val">{{ s.value }}</div>
<div class="mcr-stat__lbl">{{ s.label }}</div>
</div>
</div>
</div>
<!-- Filtro de status -->
<div class="mcr-w mcr-w--side">
<div class="mcr-w__head">
<span class="mcr-w__title"><i class="pi pi-filter" /> Status</span>
<span v-if="statusFilter" class="mcr-w__count">1</span>
</div>
<div class="mcr-side__list">
<button
v-for="o in STATUS_FILTER_OPTIONS"
:key="o.key"
class="mcr-side__item"
:class="[`is-${o.key}`, { 'is-active': statusFilter === o.key }]"
@click="toggleStatusFilter(o.key)"
>
<i :class="o.icon" />
<span>{{ o.label }}</span>
</button>
<Transition name="mcr-clear">
<button
v-if="statusFilter"
class="mcr-side__item is-clear"
@click="statusFilter = ''"
>
<i class="pi pi-filter-slash" />
<span>Limpar filtro</span>
</button>
</Transition>
</div>
</div>
</aside>
</Teleport>
<div class="mcr-main">
<!-- Toolbar com busca + toggle de visualização (lista/grade).
Search no topo da coluna que contém os dados. Count
reflete o filtrado pra feedback imediato ao digitar. -->
<div class="mcr-toolbar">
<div class="mcr-search">
<i class="pi pi-search mcr-search__icon" />
<input
v-model="busca"
type="text"
placeholder="Buscar por nome, email ou telefone…"
class="mcr-search__input"
/>
<button
v-if="busca"
class="mcr-search__clear"
v-tooltip.bottom="'Limpar busca'"
@click="busca = ''"
>
<i class="pi pi-times" />
</button>
</div>
<div class="mcr-view-toggle" role="group" aria-label="Visualização">
<button
class="mcr-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="mcr-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>
</div>
<DataTable
v-if="viewMode === 'list'"
:value="filtered"
:loading="loading"
dataKey="id"
paginator
:rows="rowsMCR"
:first="firstMCR"
:rowsPerPageOptions="PAGE_SIZE_OPTIONS"
paginatorTemplate="RowsPerPageDropdown FirstPageLink PrevPageLink CurrentPageReport NextPageLink LastPageLink"
currentPageReportTemplate="{first}{last} de {totalRecords}"
:rowClass="rowStatusClass"
selectionMode="single"
scrollable
scrollHeight="flex"
tableStyle="min-width: 640px"
class="mcr-table"
@row-click="onRowClick"
@page="onPage"
>
<Column header="Paciente" style="min-width: 220px">
<template #body="{ data }">
<div class="mcr-row__patient">
<span class="mcr-card__avatar mcr-card__avatar--sm">
<img v-if="avatarUrl(data)" :src="avatarUrl(data)" :alt="fNome(data) || 'Avatar'" />
<span v-else>{{ pacienteIniciais(fNome(data)) }}</span>
</span>
<div class="mcr-row__patient-text">
<span class="mcr-row__name">{{ fNome(data) || '—' }}</span>
<span class="mcr-card__badge" :class="statusClass(data.status)">
{{ statusLabel(data.status) }}
</span>
</div>
</div>
</template>
</Column>
<Column header="Contato" style="min-width: 220px">
<template #body="{ data }">
<div class="mcr-row__contact">
<span v-if="fEmail(data)"><i class="pi pi-envelope" /> {{ fEmail(data) }}</span>
<span v-if="fTel(data)"><i class="pi pi-phone" /> {{ fmtPhoneBR(fTel(data)) }}</span>
<span v-if="!fEmail(data) && !fTel(data)" class="mcr-row__empty"></span>
</div>
</template>
</Column>
<Column header="Recebido" style="width: 130px">
<template #body="{ data }">
<span class="mcr-row__time">
<i class="pi pi-clock" /> {{ fmtRelative(data.created_at) }}
</span>
</template>
</Column>
<Column
header=""
:style="{ width: '60px', maxWidth: '60px', minWidth: '60px' }"
frozen
alignFrozen="right"
class="mcr-col-acao"
>
<template #body="{ data }">
<button
class="mcr-row__action"
v-tooltip.left="'Editar'"
aria-label="Editar"
@click.stop="openDetails(data)"
>
<i class="pi pi-pencil" />
</button>
</template>
</Column>
<template #empty>
<div class="mcr-empty">
<i class="pi pi-inbox mcr-empty__icon" />
<div class="mcr-empty__title">Nenhum cadastro encontrado</div>
<div class="mcr-empty__hint">
<template v-if="busca || statusFilter">Ajuste os filtros pra ver mais.</template>
<template v-else>Cadastros vindos do agendador externo aparecem aqui.</template>
</div>
</div>
</template>
<template #loading>
<div class="mcr-table__loading">
<i class="pi pi-spin pi-spinner" />
<span>Carregando cadastros</span>
</div>
</template>
</DataTable>
<!-- Grid view cards num CSS grid, mesma fonte de dados
(filtered + pagedItems) e Paginator standalone que
compartilha rowsMCR/firstMCR com a list view. -->
<div v-else-if="viewMode === 'grid'" class="mcr-grid-wrap">
<div v-if="loading && filtered.length === 0" class="mcr-table__loading mcr-grid__loading">
<i class="pi pi-spin pi-spinner" />
<span>Carregando cadastros</span>
</div>
<div v-else-if="filtered.length === 0" class="mcr-empty">
<i class="pi pi-inbox mcr-empty__icon" />
<div class="mcr-empty__title">Nenhum cadastro encontrado</div>
<div class="mcr-empty__hint">
<template v-if="busca || statusFilter">Ajuste os filtros pra ver mais.</template>
<template v-else>Cadastros vindos do agendador externo aparecem aqui.</template>
</div>
</div>
<div v-else class="mcr-grid">
<div
v-for="r in pagedItems"
:key="r.id"
class="mcr-grid__card"
:class="statusClass(r.status)"
role="button"
tabindex="0"
@click="openDetails(r)"
@keydown.enter.prevent="openDetails(r)"
@keydown.space.prevent="openDetails(r)"
>
<div class="mcr-grid__top">
<span class="mcr-card__avatar">
<img v-if="avatarUrl(r)" :src="avatarUrl(r)" :alt="fNome(r) || 'Avatar'" />
<span v-else>{{ pacienteIniciais(fNome(r)) }}</span>
</span>
<div class="mcr-grid__top-right">
<span class="mcr-card__badge" :class="statusClass(r.status)">
{{ statusLabel(r.status) }}
</span>
<button
class="mcr-row__action"
v-tooltip.left="'Editar'"
aria-label="Editar"
@click.stop="openDetails(r)"
>
<i class="pi pi-pencil" />
</button>
</div>
</div>
<div class="mcr-grid__name">{{ fNome(r) || '—' }}</div>
<div class="mcr-grid__meta">
<span v-if="fEmail(r)"><i class="pi pi-envelope" /> {{ fEmail(r) }}</span>
<span v-if="fTel(r)"><i class="pi pi-phone" /> {{ fmtPhoneBR(fTel(r)) }}</span>
</div>
<div class="mcr-grid__time">
<i class="pi pi-clock" /> {{ fmtRelative(r.created_at) }}
</div>
</div>
</div>
<Paginator
v-if="filtered.length > 0"
class="mcr-paginator"
:rows="rowsMCR"
:totalRecords="filtered.length"
:first="firstMCR"
:rowsPerPageOptions="PAGE_SIZE_OPTIONS"
template="RowsPerPageDropdown FirstPageLink PrevPageLink CurrentPageReport NextPageLink LastPageLink"
currentPageReportTemplate="{first}{last} de {totalRecords}"
@page="onPage"
/>
</div>
</div>
</div>
<!-- Dialog detalhes -->
<Dialog
:visible="dlg.open"
modal
dismissable-mask
:style="{ width: '640px', maxWidth: '94vw' }"
:header="fNome(dlg.item) || 'Cadastro'"
@update:visible="(v) => !v && closeDlg()"
>
<div v-if="dlg.item" class="flex flex-col gap-4">
<!-- Avatar + status + ações principais -->
<div class="flex items-start gap-3">
<div class="mcr-dlg__avatar">
<img v-if="dlgAvatarUrl" :src="dlgAvatarUrl" :alt="fNome(dlg.item)" />
<span v-else>{{ pacienteIniciais(fNome(dlg.item)) }}</span>
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-bold">{{ fNome(dlg.item) }}</div>
<div class="text-xs text-[var(--text-color-secondary)] mt-1">
<span class="mcr-card__badge" :class="statusClass(dlg.item.status)">{{ statusLabel(dlg.item.status) }}</span>
· Criado {{ fmtRelative(dlg.item.created_at) }}
</div>
</div>
</div>
<!-- Seções -->
<div v-for="sec in dlgSections" :key="sec.title" class="flex flex-col gap-1">
<div class="text-[0.62rem] uppercase tracking-wider font-semibold text-[var(--text-color-secondary)] opacity-70">{{ sec.title }}</div>
<div class="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<template v-for="r in sec.rows" :key="r.label">
<div class="text-[var(--text-color-secondary)]">{{ r.label }}</div>
<div class="text-[var(--text-color)]">{{ r.value }}</div>
</template>
</div>
</div>
<!-- Motivo da rejeição -->
<div v-if="dlg.item.status !== 'converted'" class="flex flex-col gap-1">
<label class="text-[0.62rem] uppercase tracking-wider font-semibold text-[var(--text-color-secondary)] opacity-70">
Motivo da rejeição (opcional)
</label>
<Textarea v-model="dlg.reject_note" autoResize rows="2" class="w-full text-xs" :disabled="dlg.saving" />
</div>
</div>
<template #footer>
<div class="flex items-center gap-2 w-full">
<Button label="Fechar" text @click="closeDlg" />
<div class="flex-1" />
<Button
v-if="dlg.item && dlg.item.status !== 'rejected' && dlg.item.status !== 'converted'"
label="Rejeitar"
severity="danger"
outlined
:disabled="dlg.saving || converting"
@click="markRejected"
/>
<Button
v-if="dlg.item && dlg.item.status !== 'converted'"
:label="converting ? 'Convertendo…' : 'Converter em paciente'"
:loading="converting"
:disabled="dlg.saving || converting"
@click="convertToPatient"
/>
</div>
</template>
</Dialog>
</section>
</template>
<style scoped>
.mcr-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: mcr-page-enter 240ms cubic-bezier(0.2, 0.7, 0.3, 1);
}
@keyframes mcr-page-enter {
from { opacity: 0; transform: scale(0.985); }
to { opacity: 1; transform: scale(1); }
}
.mcr-page__head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid var(--m-border);
flex-shrink: 0;
gap: 10px;
}
.mcr-page__title {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
font-size: 1rem;
font-weight: 500;
}
/* Ícone do título — cor primary do tema (espelha o token PrimeVue).
Tamanho levemente maior que o label pra ler como ícone identitário. */
.mcr-page__title-icon {
color: var(--p-primary-color);
font-size: 1.05rem;
}
.mcr-page__title > span:not(.mcr-page__count) {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mcr-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;
}
.mcr-page__actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
.mcr-close, .mcr-head-btn {
width: 32px; height: 32px;
display: grid; place-items: center;
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
color: var(--m-text);
border-radius: 9px;
cursor: pointer;
font-family: inherit;
transition: background-color 140ms ease;
}
.mcr-close:hover, .mcr-head-btn:hover { background: var(--m-bg-soft-hover); }
.mcr-head-btn > i { font-size: 0.85rem; }
.mcr-menu-btn {
display: none;
height: 32px;
align-items: center;
gap: 6px;
flex-shrink: 0;
background: var(--m-accent);
border: 1px solid var(--m-accent);
color: white;
padding: 0 11px;
border-radius: 9px;
cursor: pointer;
font-family: inherit;
font-size: 0.78rem;
font-weight: 600;
transition: background-color 140ms ease, transform 140ms ease;
}
.mcr-menu-btn:hover {
background: color-mix(in srgb, var(--m-accent) 88%, white);
transform: translateY(-1px);
}
/* Subheader explicativo — faixa abaixo do page__head com texto contextual
sobre a página. Bg sutil + ícone primary + tipografia menor. Diferencia
esta página de outras tabelas Melissa que têm layout visual idêntico. */
.mcr-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;
}
.mcr-subheader__icon {
color: var(--p-primary-color);
font-size: 0.92rem;
flex-shrink: 0;
margin-top: 1px;
}
.mcr-subheader__text {
flex: 1;
min-width: 0;
}
.mcr-subheader__text strong {
color: var(--m-text);
font-weight: 600;
}
.mcr-body {
flex: 1;
display: flex;
min-height: 0;
gap: 0;
padding: 0;
}
/* Sidebar — espelha .ma-side do MelissaAgenda: bg colorido próprio
(--m-bg-soft) + border-right pra separar visualmente da coluna
principal. Cards internos (.mcr-w--side) ficam mais opacos
(--m-bg-medium) pra contrastar com o bg da sidebar. */
.mcr-side {
width: 280px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 0;
overflow-y: auto;
background: var(--m-bg-soft);
border-right: 1px solid var(--m-border);
}
.mcr-side::-webkit-scrollbar { width: 5px; }
.mcr-side::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
/* Card-base — alinhado com .ma-w (MelissaAgenda) e .mp-w (MelissaPacientes):
surface --m-bg-medium pra destacar do bg da sidebar (--m-bg-soft). */
.mcr-w {
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 12px;
padding: 12px;
}
/* Modificador pra usar .mcr-w dentro da sidebar (col 1) — adiciona
margem nas bordas (a .mcr-side não tem padding interno) + sombra
sutil pra elevação visual sobre o bg colorido da sidebar.
Espelha .ma-w--side do MelissaAgenda. */
.mcr-w--side {
margin: 12px 12px 0;
flex-shrink: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
.mcr-w--side:last-of-type { margin-bottom: 12px; }
/* Header e título no padrão do .ma-w__head/__title — uppercase com
letter-spacing pra hierarquia visual de label da sidebar. */
.mcr-w__head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.mcr-w__title {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--m-text-muted);
font-weight: 600;
}
.mcr-w__title > i { color: var(--m-text-muted); font-size: 0.7rem; }
.mcr-w__count {
font-size: 0.65rem;
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: 1px 7px;
border-radius: 999px;
}
.mcr-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 6px;
}
.mcr-stat {
/* Mais sutil que o card-pai (.mcr-w em --m-bg-medium) pra criar contraste
interno. Antes usava --m-bg-medium e ficava igual ao pai. */
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 10px;
padding: 8px 10px;
}
.mcr-stat__val {
font-size: 1.1rem;
font-weight: 600;
line-height: 1.1;
}
.mcr-stat__lbl {
font-size: 0.65rem;
color: var(--m-text-muted);
margin-top: 4px;
text-transform: uppercase;
letter-spacing: 0.06em;
}
/* Cores fortes (Tailwind 600) pra ler bem em ambos os modos. Light:
suficientemente escuras pra contrastar com bg branco. Dark: ainda
saturadas o bastante pra destacar do bg quase preto. */
.mcr-stat.is-info .mcr-stat__val { color: rgb(37, 99, 235); } /* blue-600 */
.mcr-stat.is-ok .mcr-stat__val { color: rgb(22, 163, 74); } /* green-600 */
.mcr-stat.is-danger .mcr-stat__val { color: rgb(220, 38, 38); } /* red-600 */
.mcr-side__list {
display: flex;
flex-direction: column;
gap: 4px;
}
/* Botões do filtro de status — coloridos em 3 níveis:
- Default: tint 5% bg + 18% border + ícone full color → identifica
o status mesmo sem clicar
- Hover: tint 10% bg + 30% border
- Active: tint 16% bg + 55% border + box-shadow ring pro destaque
Cores Tailwind 600: blue/green/red — fortes pra ler em light. */
.mcr-side__item {
width: 100%;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
background: transparent;
border: 1px solid transparent;
color: var(--m-text);
border-radius: 10px;
cursor: pointer;
font-family: inherit;
font-size: 0.82rem;
text-align: left;
transition: background-color 140ms ease, border-color 140ms ease, box-shadow 140ms ease;
}
.mcr-side__item > i {
font-size: 0.78rem;
width: 14px;
text-align: center;
}
/* Novo — azul */
.mcr-side__item.is-new {
background: rgba(37, 99, 235, 0.05);
border-color: rgba(37, 99, 235, 0.18);
}
.mcr-side__item.is-new > i { color: rgb(37, 99, 235); }
.mcr-side__item.is-new:hover {
background: rgba(37, 99, 235, 0.10);
border-color: rgba(37, 99, 235, 0.30);
}
.mcr-side__item.is-active.is-new {
background: rgba(37, 99, 235, 0.16);
border-color: rgba(37, 99, 235, 0.55);
box-shadow: 0 0 0 1px rgba(37, 99, 235, 0.35);
}
/* Convertido — verde */
.mcr-side__item.is-converted {
background: rgba(22, 163, 74, 0.05);
border-color: rgba(22, 163, 74, 0.18);
}
.mcr-side__item.is-converted > i { color: rgb(22, 163, 74); }
.mcr-side__item.is-converted:hover {
background: rgba(22, 163, 74, 0.10);
border-color: rgba(22, 163, 74, 0.30);
}
.mcr-side__item.is-active.is-converted {
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);
}
/* Rejeitado — vermelho */
.mcr-side__item.is-rejected {
background: rgba(220, 38, 38, 0.05);
border-color: rgba(220, 38, 38, 0.18);
}
.mcr-side__item.is-rejected > i { color: rgb(220, 38, 38); }
.mcr-side__item.is-rejected:hover {
background: rgba(220, 38, 38, 0.10);
border-color: rgba(220, 38, 38, 0.30);
}
.mcr-side__item.is-active.is-rejected {
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);
}
/* Limpar filtro — botão neutro que aparece como 4ª linha quando há
um filtro ativo. Cor sutil, apenas pra ler como ação secundária. */
.mcr-side__item.is-clear {
margin-top: 4px;
background: var(--m-bg-soft);
border-color: var(--m-border);
color: var(--m-text-muted);
font-style: italic;
}
.mcr-side__item.is-clear > i { color: var(--m-text-muted); }
.mcr-side__item.is-clear:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
color: var(--m-text);
}
.mcr-side__item.is-clear:hover > i { color: var(--m-text); }
/* Transition do botão "Limpar filtro" — fade + leve slide vertical
pra entrada/saída suave. Aproveita os 4px de margin-top como ponto
de partida do slide (sai de cima pra encaixar). */
.mcr-clear-enter-active,
.mcr-clear-leave-active {
transition: opacity 220ms ease, transform 220ms ease, max-height 220ms ease, margin-top 220ms ease;
overflow: hidden;
}
.mcr-clear-enter-from,
.mcr-clear-leave-to {
opacity: 0;
transform: translateY(-4px);
max-height: 0;
margin-top: 0;
}
.mcr-clear-enter-to,
.mcr-clear-leave-from {
opacity: 1;
transform: translateY(0);
max-height: 40px;
}
/* Main column (col 2) — busca no topo (toolbar) + lista scrollável +
paginator fixo no rodapé. min-height:0 é CRÍTICO pro flex:1 da lista
funcionar dentro de um flex container (sem isso, .mcr-list cresce
indefinidamente em vez de scrollar). */
.mcr-main {
flex: 1;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
padding: 12px;
gap: 10px;
}
/* Toolbar — wrapper da busca no topo da coluna principal. Mesmo padrão
visual do .ma-tsearch (toolbar de busca da MelissaAgenda). */
.mcr-toolbar {
flex-shrink: 0;
display: flex;
align-items: center;
gap: 10px;
}
.mcr-search {
position: relative;
flex: 1;
min-width: 0;
display: flex;
align-items: center;
}
.mcr-search__icon {
position: absolute;
left: 12px;
color: var(--m-text-muted);
font-size: 0.78rem;
pointer-events: none;
}
.mcr-search__input {
width: 100%;
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
color: var(--m-text);
padding: 9px 36px 9px 34px;
border-radius: 10px;
font-size: 0.85rem;
font-family: inherit;
outline: none;
transition: background-color 140ms ease, border-color 140ms ease;
}
.mcr-search__input::placeholder { color: var(--m-text-faint); }
.mcr-search__input:focus {
border-color: var(--m-border-strong);
background: var(--m-bg-medium);
}
.mcr-search__clear {
position: absolute;
right: 8px;
width: 22px;
height: 22px;
display: grid;
place-items: center;
background: transparent;
border: none;
color: var(--m-text-muted);
border-radius: 6px;
cursor: pointer;
font-family: inherit;
transition: background-color 140ms ease, color 140ms ease;
}
.mcr-search__clear:hover {
background: var(--m-bg-soft-hover);
color: var(--m-text);
}
.mcr-search__clear > i { font-size: 0.7rem; }
/* Toggle de visualização (lista/grade) — segmented control compacto
à direita da busca. Botão ativo ganha bg primary-tinted. */
.mcr-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;
}
.mcr-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;
}
.mcr-view-toggle__btn:hover {
background: var(--m-bg-soft-hover);
color: var(--m-text);
}
.mcr-view-toggle__btn.is-active {
background: var(--m-accent-soft);
color: var(--m-accent);
}
.mcr-view-toggle__btn > i { font-size: 0.85rem; }
/* ─── DataTable (.mcr-table) ─────────────────────────────────────
Substitui a antiga lista de cards. `:loading="loading"` cobre o
estado de carregamento (template #loading) e `paginator` integra
o seletor de tamanho + navegação. scrollHeight="flex" faz a tabela
preencher o flex restante da .mcr-main e scrollar internamente. */
.mcr-table {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.mcr-table :deep(.p-datatable) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: transparent;
border: 1px solid var(--m-border);
border-radius: 10px;
overflow: hidden;
}
.mcr-table :deep(.p-datatable-table-container) {
flex: 1;
min-height: 0;
background: transparent;
}
/* Header — usa --p-content-background (token "surface" do tema PrimeVue).
Em light mode → branco; em dark mode → surface configurado (dark).
Mesma fonte que os cards Estatísticas/Status visualizam (eles usam
--m-bg-medium, que em light é --p-content-background opaco). */
.mcr-table :deep(.p-datatable-thead),
.mcr-table :deep(.p-datatable-thead > tr) {
background: transparent !important;
}
.mcr-table :deep(.p-datatable-thead > tr > th) {
background: var(--p-content-background) !important;
color: var(--m-text);
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
padding: 10px 14px;
border-bottom: 1px solid var(--m-border);
}
.mcr-table :deep(.p-datatable-tbody > tr) {
background: transparent;
color: var(--m-text);
cursor: pointer;
transition: background-color 140ms ease;
border-left: 3px solid var(--m-border);
}
.mcr-table :deep(.p-datatable-tbody > tr > td) {
padding: 10px 14px;
border-bottom: 1px solid var(--m-border);
background: transparent;
vertical-align: middle;
}
.mcr-table :deep(.p-datatable-tbody > tr:hover) {
background: var(--m-bg-soft-hover);
}
.mcr-table :deep(.p-datatable-tbody > tr.p-datatable-row-selected) {
background: var(--m-accent-soft);
}
/* Border-left colorido por status — espelha o padrão .ma-sess. */
.mcr-table :deep(.p-datatable-tbody > tr.is-new) { border-left-color: rgb(37, 99, 235); }
.mcr-table :deep(.p-datatable-tbody > tr.is-done) { border-left-color: rgb(22, 163, 74); }
.mcr-table :deep(.p-datatable-tbody > tr.is-rejected) { border-left-color: rgb(220, 38, 38); opacity: 0.85; }
/* Loading overlay */
.mcr-table :deep(.p-datatable-loading-overlay) {
background: color-mix(in srgb, var(--m-bg-medium) 70%, transparent);
backdrop-filter: blur(2px);
}
.mcr-table__loading {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--m-text);
font-size: 0.85rem;
}
/* Paginator integrado do DataTable — centralizado (sem refresh à
esquerda; o refresh já vive no header da página). */
.mcr-table :deep(.p-paginator) {
background: var(--m-bg-medium);
border: none;
border-top: 1px solid var(--m-border);
padding: 8px 12px;
justify-content: center;
flex-wrap: wrap;
gap: 6px;
}
.mcr-table :deep(.p-paginator-current) {
color: var(--m-text-muted);
font-size: 0.78rem;
background: transparent;
border: none;
padding: 0 6px;
}
.mcr-table :deep(.p-paginator-first),
.mcr-table :deep(.p-paginator-prev),
.mcr-table :deep(.p-paginator-next),
.mcr-table :deep(.p-paginator-last),
.mcr-table :deep(.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;
}
.mcr-table :deep(.p-paginator-page.p-paginator-page-selected) {
background: var(--m-accent-soft);
border-color: var(--m-accent-strong);
color: var(--m-accent);
}
.mcr-table :deep(.p-paginator-first:not(.p-disabled):hover),
.mcr-table :deep(.p-paginator-prev:not(.p-disabled):hover),
.mcr-table :deep(.p-paginator-next:not(.p-disabled):hover),
.mcr-table :deep(.p-paginator-last:not(.p-disabled):hover),
.mcr-table :deep(.p-paginator-page:not(.p-paginator-page-selected):hover) {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mcr-table :deep(.p-select) {
background: transparent;
border: 1px solid var(--m-border);
border-radius: 8px;
height: 30px;
font-size: 0.78rem;
color: var(--m-text);
display: inline-flex;
align-items: center;
}
.mcr-table :deep(.p-select-label) {
padding: 0 8px;
color: var(--m-text);
font-size: 0.78rem;
display: flex;
align-items: center;
line-height: 1;
height: 100%;
background: transparent;
}
/* ─── Grid view ───────────────────────────────────────────────
Cards num CSS grid responsivo (auto-fill 220px+). Cada card tem
avatar + badge no topo, nome em destaque, contato e tempo abaixo.
Border-left colorido por status (mesmo padrão da list view). */
.mcr-grid-wrap {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 10px;
border: 1px solid var(--m-border);
border-radius: 10px;
overflow: hidden;
background: transparent;
}
.mcr-grid {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 12px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 10px;
align-content: start;
}
.mcr-grid::-webkit-scrollbar { width: 5px; }
.mcr-grid::-webkit-scrollbar-thumb { background: var(--m-border-strong); border-radius: 3px; }
.mcr-grid__loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
}
.mcr-grid__card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px;
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-left: 3px solid var(--m-border-strong);
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;
}
.mcr-grid__card:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
transform: translateY(-1px);
}
.mcr-grid__card:focus-visible {
outline: 2px solid var(--p-primary-color);
outline-offset: 2px;
}
.mcr-grid__card.is-new { border-left-color: rgb(37, 99, 235); }
.mcr-grid__card.is-done { border-left-color: rgb(22, 163, 74); }
.mcr-grid__card.is-rejected { border-left-color: rgb(220, 38, 38); opacity: 0.85; }
.mcr-grid__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
/* Cluster badge + pencil à direita do top do card. */
.mcr-grid__top-right {
display: inline-flex;
align-items: center;
gap: 6px;
}
.mcr-grid__name {
font-size: 0.92rem;
font-weight: 600;
color: var(--m-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mcr-grid__meta {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.75rem;
color: var(--m-text-muted);
min-width: 0;
}
.mcr-grid__meta i { margin-right: 5px; font-size: 0.7rem; }
.mcr-grid__meta span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mcr-grid__time {
font-size: 0.7rem;
color: var(--m-text-faint);
display: inline-flex;
align-items: center;
gap: 4px;
margin-top: auto;
}
.mcr-grid__time i { font-size: 0.65rem; }
/* Paginator standalone (grid view) — espelha o estilo do paginator
interno do DataTable, mas via classe direta (sem :deep). Centralizado
e sem bg de surface (consistente com o pedido de "fundo transparente"). */
.mcr-paginator.p-paginator {
background: var(--m-bg-medium);
border: none;
border-top: 1px solid var(--m-border);
padding: 8px 12px;
justify-content: center;
flex-wrap: wrap;
gap: 6px;
flex-shrink: 0;
}
.mcr-paginator.p-paginator .p-paginator-current {
color: var(--m-text-muted);
font-size: 0.78rem;
background: transparent;
border: none;
padding: 0 6px;
}
.mcr-paginator.p-paginator .p-paginator-first,
.mcr-paginator.p-paginator .p-paginator-prev,
.mcr-paginator.p-paginator .p-paginator-next,
.mcr-paginator.p-paginator .p-paginator-last,
.mcr-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;
}
.mcr-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);
}
.mcr-paginator.p-paginator .p-paginator-first:not(.p-disabled):hover,
.mcr-paginator.p-paginator .p-paginator-prev:not(.p-disabled):hover,
.mcr-paginator.p-paginator .p-paginator-next:not(.p-disabled):hover,
.mcr-paginator.p-paginator .p-paginator-last:not(.p-disabled):hover,
.mcr-paginator.p-paginator .p-paginator-page:not(.p-paginator-page-selected):hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
}
.mcr-paginator.p-paginator .p-select {
background: transparent;
border: 1px solid var(--m-border);
border-radius: 8px;
height: 30px;
font-size: 0.78rem;
color: var(--m-text);
display: inline-flex;
align-items: center;
}
.mcr-paginator.p-paginator .p-select-label {
padding: 0 8px;
color: var(--m-text);
font-size: 0.78rem;
display: flex;
align-items: center;
line-height: 1;
height: 100%;
background: transparent;
}
/* Conteúdo das células — substitui o antigo .mcr-card__* (interno). */
.mcr-row__patient {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.mcr-row__patient-text {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.mcr-row__name {
font-size: 0.88rem;
font-weight: 600;
color: var(--m-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mcr-row__contact {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.78rem;
color: var(--m-text-muted);
min-width: 0;
}
.mcr-row__contact i { margin-right: 5px; font-size: 0.7rem; }
.mcr-row__contact span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mcr-row__empty { color: var(--m-text-faint); }
.mcr-row__time {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 0.78rem;
color: var(--m-text-muted);
}
.mcr-row__time i { font-size: 0.7rem; }
/* Botão de ação (pencil) — ícone primary + bg matching da célula
pra não ficar "vazado" durante scroll horizontal (a célula frozen
da Ação usa --m-bg-medium; o botão herda o mesmo token, que adapta
a dark/light automaticamente). @click.stop no template impede
propagar pro row-click do DataTable. */
.mcr-row__action {
width: 30px;
height: 30px;
display: grid;
place-items: center;
background: var(--p-content-background);
border: 1px solid color-mix(in srgb, var(--p-primary-color) 30%, var(--m-border));
color: var(--p-primary-color);
border-radius: 8px;
cursor: pointer;
font-family: inherit;
transition: background-color 140ms ease, border-color 140ms ease, color 140ms ease;
}
.mcr-row__action: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);
}
.mcr-row__action > i { font-size: 0.78rem; }
/* Coluna "Ação" frozen à direita — bg sólido em ambos os modos
(--p-content-background): branco no light, surface configurada
no dark. Mesma referência visual que o header e os cards. */
.mcr-table :deep(td.p-datatable-frozen-column),
.mcr-table :deep(th.p-datatable-frozen-column) {
background: var(--p-content-background) !important;
box-shadow: -3px 0 6px -3px rgba(0, 0, 0, 0.18);
z-index: 1;
}
.mcr-table :deep(.p-datatable-tbody > tr:hover td.p-datatable-frozen-column) {
background: var(--m-bg-soft-hover);
}
.mcr-table :deep(.p-datatable-tbody > tr.p-datatable-row-selected td.p-datatable-frozen-column) {
background: var(--m-accent-soft);
}
/* Avatar — usado no row da tabela (modificador --sm) e no dialog (size full). */
.mcr-card__avatar {
width: 40px; height: 40px;
border-radius: 50%;
background: var(--m-accent-strong);
border: 1px solid var(--m-accent);
color: white;
font-size: 0.78rem;
font-weight: 600;
display: grid; place-items: center;
flex-shrink: 0;
overflow: hidden;
}
.mcr-card__avatar img { width: 100%; height: 100%; object-fit: cover; }
.mcr-card__avatar--sm {
width: 32px; height: 32px;
font-size: 0.7rem;
}
.mcr-card__badge {
display: inline-flex;
align-items: center;
font-size: 0.62rem;
font-weight: 600;
padding: 2px 8px;
border-radius: 999px;
text-transform: uppercase;
letter-spacing: 0.04em;
border: 1px solid;
}
.mcr-card__badge.is-new {
color: rgb(37, 99, 235);
background: rgba(37, 99, 235, 0.12);
border-color: rgba(37, 99, 235, 0.3);
}
.mcr-card__badge.is-done {
color: rgb(22, 163, 74);
background: rgba(22, 163, 74, 0.12);
border-color: rgba(22, 163, 74, 0.3);
}
.mcr-card__badge.is-rejected {
color: rgb(220, 38, 38);
background: rgba(220, 38, 38, 0.12);
border-color: rgba(220, 38, 38, 0.3);
}
.mcr-empty {
margin: 24px 0;
padding: 56px 28px;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
color: var(--m-text-muted);
border: 2px dashed var(--m-border-strong);
border-radius: 12px;
background: color-mix(in srgb, var(--m-bg-soft) 40%, transparent);
gap: 8px;
}
.mcr-empty__icon { font-size: 2rem; color: var(--m-text-faint); margin-bottom: 4px; }
.mcr-empty__title { font-size: 0.92rem; font-weight: 600; color: var(--m-text); }
.mcr-empty__hint { font-size: 0.78rem; }
/* Dialog avatar */
.mcr-dlg__avatar {
width: 56px; height: 56px;
border-radius: 50%;
background: var(--m-accent-strong);
border: 1px solid var(--m-accent);
color: white;
font-size: 1rem;
font-weight: 600;
display: grid; place-items: center;
flex-shrink: 0;
overflow: hidden;
}
.mcr-dlg__avatar img { width: 100%; height: 100%; object-fit: cover; }
/* Drawer mobile */
.mcr-mobile-drawer {
position: fixed;
top: 0; left: 0;
height: 100vh;
height: 100dvh;
width: min(360px, 88vw);
z-index: 80;
background: var(--m-bg-medium);
backdrop-filter: blur(28px) saturate(160%);
-webkit-backdrop-filter: blur(28px) saturate(160%);
border-right: 1px solid var(--m-border);
transform: translateX(-100%);
transition: transform 250ms cubic-bezier(0.4, 0, 0.2, 1);
color: var(--m-text);
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
}
.mcr-mobile-drawer.is-open { transform: translateX(0); }
.mcr-mobile-drawer__scroll {
height: 100%;
overflow-y: auto;
padding: 12px 12px 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
.mcr-mobile-drawer__scroll::-webkit-scrollbar { width: 5px; }
.mcr-mobile-drawer__scroll::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
/* Reset da .mcr-side quando teleportada pro drawer mobile — o drawer
já tem bg/border próprios (.mcr-mobile-drawer), então a sidebar não
precisa do bg/border-right que tem em desktop. */
.mcr-mobile-drawer__scroll .mcr-side {
width: 100%;
flex: 1;
min-height: 0;
overflow: visible;
padding: 0;
background: transparent;
border-right: none;
display: flex;
flex-direction: column;
}
.mcr-mobile-drawer__scroll .mcr-w--side {
margin: 0 0 12px;
}
.mcr-mobile-drawer__scroll .mcr-w--side:last-of-type {
margin-bottom: 0;
}
.mcr-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;
}
.mcr-drawer-fade-enter-active,
.mcr-drawer-fade-leave-active { transition: opacity 200ms ease; }
.mcr-drawer-fade-enter-from,
.mcr-drawer-fade-leave-to { opacity: 0; }
@media (max-width: 1023px) {
/* Em mobile, a sidebar é teleportada pro drawer (mcr-mobile-drawer),
então .mcr-body só contém .mcr-main. Padding de 0 no body, padding
no .mcr-main já cobre o spacing interno. */
.mcr-body { flex-direction: column; padding: 0; }
.mcr-main { width: 100%; padding: 8px; }
.mcr-page__title > span:first-of-type { display: none; }
.mcr-page__title-icon { display: none; }
.mcr-menu-btn--mobile-only { display: inline-flex; }
/* Em mobile mostra todas as colunas — scroll horizontal habilitado
via tableStyle min-width:640px. Coluna "Ação" frozen à direita
continua fixa enquanto o user scrolla horizontalmente. */
}
/* ═══════ Fake dialog: largura adaptativa (>=1024px) ═══════
Espelha pattern de MelissaAgendador/Negocio — fica mesma janela,
drawer a esquerda, sobra espaco a direita pra dock e contexto.
- 10241012px : full-width (right: 6px) — overlap minimo
- 10122012px : width = 1000px fixo (right cresce com viewport)
- >= 2012px : width = ~50% do viewport (right: 50%) */
@media (min-width: 1024px) {
.mcr-page {
right: max(6px, min(50%, calc(100% - 1006px)));
}
}
</style>