Files
agenciapsilmno/src/layout/melissa/MelissaCadastrosRecebidos.vue
T
Leonardo 86311ef305 Melissa: hub Configuracoes + Embed + 9 Pages novas + dialog blueprint dark
Sprints 04-29 + 04-30 acumuladas.

- MelissaConfiguracoes: hub 2-col com 6 grupos (Layout/Conta/Agenda/
  Financeiro/WhatsApp/Sistema), tudo embedado via MelissaEmbed.
- MelissaEmbed: wrapper generico que injeta layout-variant=melissa
  e remove cromos pra reaproveitar Pages tradicionais.
- 9 Melissa Pages novas: CadastrosRecebidos, Compromissos, Configuracoes,
  Conversas, Embed, Grupos, Medicos, Recorrencias, Tags.
- Dialog blueprint atualizado: bg-gray-100 (hardcoded light) ->
  bg-[var(--surface-ground)] (tema-aware). 22 dialogs migrados em
  9 arquivos. Anti-pattern documentado.
- PatientsCadastroPage: bug fix dropdown Grupo (optionLabel nome->name),
  toggle vertical/abas com persist localStorage, sticky margin-top.
- Surface picker no popover do MelissaLayout (8 swatches).
- useTopbarPlanMenu, useMelissaWhatsapp, useMelissaPacientesAside novos.
- Migration: status agenda remarcado/confirmado.

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

1174 lines
44 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';
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 ? 'warn' : 'neutral' },
{ key: 'converted', label: 'Convertidos', value: c, cls: 'ok' },
{ key: 'rejected', label: 'Rejeitados', value: r, cls: '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 (default 10 por página) ──────────
// `fetchIntakes` traz a lista completa de patient_intake_requests do
// tenant — paginamos no client. Volume típico é baixo (cadastros
// recebidos via formulário externo), mas o Paginator + dropdown dão
// controle pro usuário caso a fila esteja grande.
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
const pageMCR = ref(1);
const rowsMCR = ref(10);
const filteredPaginated = computed(() => {
const start = (pageMCR.value - 1) * rowsMCR.value;
return filtered.value.slice(start, start + rowsMCR.value);
});
function onPageMCRChange(event) {
pageMCR.value = event.page + 1;
rowsMCR.value = event.rows;
}
// Reset pra página 1 quando busca/status mudam.
watch([busca, statusFilter], () => { pageMCR.value = 1; });
// Clamp se a lista encolher (ex.: depois de converter/rejeitar e refetch).
watch(() => filtered.value.length, (len) => {
const maxPag = Math.max(1, Math.ceil(len / rowsMCR.value));
if (pageMCR.value > maxPag) pageMCR.value = maxPag;
});
// ── 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];
});
const { data: created, error: insErr } = await supabase.from('patients')
.insert(patientPayload).select('id').single();
if (insErr) throw insErr;
const patientId = created?.id;
if (!patientId) throw new Error('Falha ao obter ID do paciente.');
const { error: upErr } = await supabase.from('patient_intake_requests')
.update({ status: 'converted', converted_patient_id: patientId, updated_at: new Date().toISOString() })
.eq('id', item.id);
if (upErr) throw upErr;
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 text-amber-300" />
<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>
<div class="mcr-body">
<Teleport to="#mcr-mobile-drawer-target" :disabled="!isMobile">
<aside class="mcr-side">
<!-- Stats -->
<div class="mcr-w">
<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">
<div class="mcr-w__head">
<span class="mcr-w__title"><i class="pi pi-filter" /> Status</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>
</div>
</div>
<!-- Busca -->
<div class="mcr-w">
<div class="mcr-w__head">
<span class="mcr-w__title"><i class="pi pi-search" /> Buscar</span>
</div>
<input
v-model="busca"
type="text"
placeholder="Nome, email ou telefone…"
class="mcr-search__input"
/>
</div>
</aside>
</Teleport>
<div class="mcr-main">
<div class="mcr-list">
<template v-if="carregandoInicial">
<div v-for="i in 5" :key="`csk-${i}`" class="mcr-card mcr-card--skeleton" aria-busy="true">
<span class="mcr-card__avatar melissa-skeleton melissa-skeleton--avatar" />
<div style="flex:1; display:flex; flex-direction:column; gap:6px;">
<span class="melissa-skeleton melissa-skeleton--title" :style="{ width: `${50 + (i * 9) % 30}%` }" />
<span class="melissa-skeleton melissa-skeleton--text" style="width: 60%;" />
</div>
</div>
</template>
<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>
<button
v-for="r in filteredPaginated"
v-else
:key="r.id"
class="mcr-card"
:class="statusClass(r.status)"
@click="openDetails(r)"
>
<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-card__main">
<div class="mcr-card__name-row">
<span class="mcr-card__name">{{ fNome(r) || '—' }}</span>
<span class="mcr-card__badge" :class="statusClass(r.status)">
{{ statusLabel(r.status) }}
</span>
</div>
<div class="mcr-card__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>
<div class="mcr-card__time">
<i class="pi pi-clock" /> {{ fmtRelative(r.created_at) }}
</div>
</button>
</div>
<Paginator
v-if="!carregandoInicial && filtered.length > 0"
class="mcr-paginator"
:rows="rowsMCR"
:totalRecords="filtered.length"
:first="(pageMCR - 1) * rowsMCR"
:rowsPerPageOptions="PAGE_SIZE_OPTIONS"
template="CurrentPageReport FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
currentPageReportTemplate="{first}{last} de {totalRecords}"
@page="onPageMCRChange"
/>
</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;
}
.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);
}
.mcr-body {
flex: 1;
display: flex;
min-height: 0;
gap: 12px;
padding: 12px;
}
.mcr-side {
width: 280px;
flex-shrink: 0;
display: flex;
flex-direction: column;
gap: 12px;
overflow-y: auto;
}
.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 página/dialog (ambos --m-bg-soft). */
.mcr-w {
background: var(--m-bg-medium);
border: 1px solid var(--m-border);
border-radius: 12px;
padding: 12px;
}
.mcr-w__head { margin-bottom: 10px; }
.mcr-w__title {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 0.78rem;
font-weight: 600;
}
.mcr-w__title > i { color: var(--m-text-muted); font-size: 0.78rem; }
.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;
}
.mcr-stat.is-warn .mcr-stat__val { color: rgb(251, 191, 36); }
.mcr-stat.is-ok .mcr-stat__val { color: rgb(74, 222, 128); }
.mcr-side__list {
display: flex;
flex-direction: column;
gap: 4px;
}
.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;
}
.mcr-side__item:hover { background: var(--m-bg-soft-hover); }
.mcr-side__item.is-active.is-new {
background: rgba(251, 191, 36, 0.08);
border-color: rgba(251, 191, 36, 0.35);
}
.mcr-side__item.is-active.is-converted {
background: rgba(74, 222, 128, 0.08);
border-color: rgba(74, 222, 128, 0.35);
}
.mcr-side__item.is-active.is-rejected {
background: rgba(248, 113, 113, 0.08);
border-color: rgba(248, 113, 113, 0.35);
}
.mcr-side__item > i { font-size: 0.75rem; color: var(--m-text-muted); width: 14px; text-align: center; }
.mcr-side__item.is-active.is-new > i { color: rgb(251, 191, 36); }
.mcr-side__item.is-active.is-converted > i { color: rgb(74, 222, 128); }
.mcr-side__item.is-active.is-rejected > i { color: rgb(248, 113, 113); }
.mcr-search__input {
width: 100%;
/* Mais sutil que o card-pai pra ler como input dentro do card. */
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
color: var(--m-text);
padding: 8px 12px;
border-radius: 9px;
font-size: 0.82rem;
font-family: inherit;
outline: none;
}
.mcr-search__input:focus { border-color: var(--m-border-strong); background: var(--m-bg-soft-hover); }
.mcr-main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.mcr-list {
flex: 1;
overflow-y: auto;
padding: 0 4px 4px;
display: flex;
flex-direction: column;
gap: 8px;
}
.mcr-list::-webkit-scrollbar { width: 5px; }
.mcr-list::-webkit-scrollbar-thumb {
background: var(--m-border-strong);
border-radius: 3px;
}
/* Paginator no rodapé da col 2 — fixo (fora do scroll), com seletor de
tamanho (10/20/50/100). Mesma estética do .mp-paginator (MelissaPacientes). */
.mcr-paginator.p-paginator {
background: transparent;
border: none;
border-top: 1px solid var(--m-border);
padding: 8px 12px;
margin-top: 4px;
justify-content: space-between;
flex-wrap: wrap;
gap: 6px;
}
.mcr-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;
}
.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-paginator-first.p-disabled,
.mcr-paginator.p-paginator .p-paginator-prev.p-disabled,
.mcr-paginator.p-paginator .p-paginator-next.p-disabled,
.mcr-paginator.p-paginator .p-paginator-last.p-disabled {
opacity: 0.35;
}
.mcr-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);
}
.mcr-paginator.p-paginator .p-select:hover { border-color: var(--m-border-strong); }
.mcr-paginator.p-paginator .p-select-label {
padding: 0 8px;
color: var(--m-text);
font-size: 0.78rem;
}
.mcr-card {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
background: var(--m-bg-soft);
border: 1px solid var(--m-border);
border-radius: 10px;
cursor: pointer;
transition: background-color 140ms ease, border-color 140ms ease, transform 140ms ease;
text-align: left;
font-family: inherit;
color: var(--m-text);
}
.mcr-card:hover {
background: var(--m-bg-soft-hover);
border-color: var(--m-border-strong);
transform: translateY(-1px);
}
.mcr-card.is-new { border-left: 3px solid rgb(251, 191, 36); }
.mcr-card.is-done { opacity: 0.75; }
.mcr-card.is-rejected { opacity: 0.55; }
.mcr-card--skeleton { cursor: default; pointer-events: none; opacity: 0.95; }
.mcr-card--skeleton:hover { background: var(--m-bg-soft); transform: none; }
.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__main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.mcr-card__name-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.mcr-card__name {
font-size: 0.92rem;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.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(251, 191, 36);
background: rgba(251, 191, 36, 0.12);
border-color: rgba(251, 191, 36, 0.3);
}
.mcr-card__badge.is-done {
color: rgb(74, 222, 128);
background: rgba(74, 222, 128, 0.12);
border-color: rgba(74, 222, 128, 0.3);
}
.mcr-card__badge.is-rejected {
color: rgb(248, 113, 113);
background: rgba(248, 113, 113, 0.12);
border-color: rgba(248, 113, 113, 0.3);
}
.mcr-card__meta {
display: flex;
gap: 14px;
margin-top: 4px;
font-size: 0.7rem;
color: var(--m-text-muted);
}
.mcr-card__meta i { margin-right: 5px; font-size: 0.65rem; }
.mcr-card__time {
flex-shrink: 0;
font-size: 0.7rem;
color: var(--m-text-muted);
display: inline-flex;
align-items: center;
gap: 4px;
}
.mcr-card__time i { font-size: 0.65rem; }
.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;
}
.mcr-mobile-drawer__scroll .mcr-side {
width: 100%;
height: auto;
overflow: visible;
padding: 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) {
.mcr-body { flex-direction: column; padding: 8px; }
.mcr-main { width: 100%; }
.mcr-page__title > span:first-of-type { display: none; }
.mcr-menu-btn--mobile-only { display: inline-flex; }
.mcr-card__time { display: none; }
}
</style>