Files
agenciapsilmno/src/features/patients/cadastro/recebidos/CadastrosRecebidosPage.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

845 lines
43 KiB
Vue

<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/patients/cadastro/recebidos/CadastrosRecebidosPage.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
// Fase 2 (Graphify hotspot): convertToPatient duplicado em 2 pages — INSERT/UPDATE
// extraídos pro repository pra remover duplicação.
import { createPatient, markIntakeConverted } from '@/features/patients/services/patientsRepository';
import { logError } from '@/support/supportLogger';
import { useConfirm } from 'primevue/useconfirm';
import { useToast } from 'primevue/usetoast';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import Avatar from 'primevue/avatar';
import Menu from 'primevue/menu';
import Textarea from 'primevue/textarea';
import { brToISO, isoToBR } from '@/utils/dateBR';
const toast = useToast();
const confirm = useConfirm();
const tenantStore = useTenantStore();
const converting = ref(false);
const loading = ref(false);
const hasLoaded = ref(false);
const rows = ref([]);
const q = ref('');
const dlg = ref({ open: false, saving: false, mode: 'view', item: null, reject_note: '' });
function statusSeverity(s) {
if (s === 'new') return 'info';
if (s === 'converted') return 'success';
if (s === 'rejected') return 'danger';
return 'secondary';
}
function statusLabel(s) {
if (s === 'new') return 'Novo';
if (s === 'converted') return 'Convertido';
if (s === 'rejected') return 'Rejeitado';
return s || '—';
}
// ── Field helpers ─────────────────────────────────────────
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 fEstadoCivil = (i) => pickField(i, ['estado_civil', 'marital_status']);
const fProf = (i) => pickField(i, ['profissao', 'profession']);
const fNacionalidade = (i) => pickField(i, ['nacionalidade', 'nationality']);
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 fCep = (i) => pickField(i, ['cep']);
const fEndereco = (i) => pickField(i, ['endereco', 'address_street']);
const fNumero = (i) => pickField(i, ['numero', 'address_number']);
const fComplemento = (i) => pickField(i, ['complemento', 'address_complement']);
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 fPais = (i) => pickField(i, ['pais', 'country']) || 'Brasil';
const fObs = (i) => pickField(i, ['observacoes', 'notes_short']);
const fNotas = (i) => pickField(i, ['notas_internas', 'notes']);
// ── Filtro ────────────────────────────────────────────────
const statusFilter = ref('');
function toggleStatusFilter(s) {
statusFilter.value = statusFilter.value === s ? '' : s;
}
const filteredRows = computed(() => {
const term = String(q.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);
});
});
// ── Avatar ────────────────────────────────────────────────
const AVATAR_BUCKET = 'avatars';
function firstNonEmpty(...vals) {
for (const v of vals) {
const s = String(v ?? '').trim();
if (s) return s;
}
return '';
}
function looksLikeUrl(s) {
return /^https?:\/\//i.test(String(s || ''));
}
function getAvatarUrlFromItem(i) {
const p = i?.payload || i?.data || i?.form || null;
const direct = firstNonEmpty(i?.avatar_url, i?.foto_url, i?.photo_url, p?.avatar_url, p?.foto_url, p?.photo_url);
if (direct && looksLikeUrl(direct)) return direct;
const path = firstNonEmpty(i?.avatar_path, i?.photo_path, i?.foto_path, i?.avatar_file_path, p?.avatar_path, p?.photo_path, p?.foto_path, p?.avatar_file_path, direct);
if (!path) return null;
if (looksLikeUrl(path)) return path;
const { data } = supabase.storage.from(AVATAR_BUCKET).getPublicUrl(path);
return data?.publicUrl || null;
}
const avatarCache = new Map();
function avatarUrl(row) {
const id = row?.id;
if (!id) return getAvatarUrlFromItem(row);
if (avatarCache.has(id)) return avatarCache.get(id);
const url = getAvatarUrlFromItem(row);
avatarCache.set(id, url);
return url;
}
const dlgAvatarUrl = computed(() => {
const item = dlg.value?.item;
if (!item) return null;
return avatarUrl(item);
});
// ── 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) return '—';
if (d.length !== 11) return d;
return `${d.slice(0, 3)}.${d.slice(3, 6)}.${d.slice(6, 9)}-${d.slice(9, 11)}`;
}
function fmtRG(v) {
const s = String(v ?? '').trim();
return s ? s : '—';
}
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)) {
const iso = s.slice(0, 10);
return isoToBR(iso) || s;
}
return s;
}
function fmtDate(iso) {
if (!iso) return '—';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return String(iso);
return d.toLocaleString('pt-BR');
}
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 `${String(d.getFullYear()).padStart(4, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
// ── Tenant / member ───────────────────────────────────────
async function getTenantIdForConversion() {
return tenantStore?.activeTenantId || tenantStore?.currentTenantId || tenantStore?.tenantId || tenantStore?.tenant?.id || null;
}
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('Responsible member not found');
return data.id;
}
// ── Seções do modal ───────────────────────────────────────
const intakeSections = computed(() => {
const i = dlg.value.item;
if (!i) return [];
const section = (title, rows) => ({ title, rows: (rows || []).filter((r) => r && r.value !== undefined) });
const row = (label, value, opts = {}) => ({ label, value, pre: !!opts.pre });
return [
section('Identificação', [
row('Nome completo', dash(fNome(i))),
row('Email principal', dash(fEmail(i))),
row('Email alternativo', dash(fEmailAlt(i))),
row('Telefone', fmtPhoneBR(fTel(i))),
row('Telefone alternativo', fmtPhoneBR(fTelAlt(i)))
]),
section('Informações pessoais', [
row('Data de nascimento', fmtBirth(fNasc(i))),
row('Gênero', dash(fGenero(i))),
row('Estado civil', dash(fEstadoCivil(i))),
row('Profissão', dash(fProf(i))),
row('Nacionalidade', dash(fNacionalidade(i))),
row('Naturalidade', dash(fNaturalidade(i))),
row('Escolaridade', dash(fEscolaridade(i))),
row('Onde nos conheceu?', dash(fOndeConheceu(i))),
row('Encaminhado por', dash(fEncaminhado(i)))
]),
section('Documentos', [row('CPF', fmtCPF(i.cpf)), row('RG', fmtRG(i.rg))]),
section('Endereço', [
row('CEP', dash(fCep(i))),
row('Endereço', dash(fEndereco(i))),
row('Número', dash(fNumero(i))),
row('Complemento', dash(fComplemento(i))),
row('Bairro', dash(fBairro(i))),
row('Cidade', dash(fCidade(i))),
row('Estado', dash(fEstado(i))),
row('País', dash(fPais(i)))
]),
section('Observações', [row('Observações', dash(fObs(i)), { pre: true }), row('Notas internas', dash(fNotas(i)), { pre: true })]),
section('Administração', [
row('Status', statusLabel(i.status)),
row('Consentimento', i.consent ? 'Aceito' : 'Não aceito'),
row('Motivo da rejeição', dash(i.rejected_reason), { pre: true }),
row('Paciente convertido (ID)', dash(i.converted_patient_id))
]),
section('Metadados', [row('Owner ID', dash(i.owner_id)), row('Token', dash(i.token)), row('Criado em', fmtDate(i.created_at)), row('Atualizado em', fmtDate(i.updated_at)), row('ID do intake', dash(i.id))])
];
});
// ── 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();
});
avatarCache.clear();
} catch (e) {
logError('patients.recebidos', 'fetchIntakes falhou', e);
toast.add({ severity: 'error', summary: 'Erro ao carregar', detail: e.message || String(e), life: 3500 });
} finally {
loading.value = false;
hasLoaded.value = true;
}
}
// ── Dialog ────────────────────────────────────────────────
function openDetails(row) {
dlg.value.open = true;
dlg.value.mode = 'view';
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 = '';
}
// ── 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',
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', detail: 'Solicitação rejeitada.', life: 2500 });
await fetchIntakes();
const updated = rows.value.find((r) => r.id === item.id);
if (updated) openDetails(updated);
} catch (e) {
logError('patients.recebidos', 'saveDetails falhou', e);
toast.add({ severity: 'error', summary: 'Erro', detail: e.message || String(e), life: 3500 });
} finally {
dlg.value.saving = false;
}
}
});
}
// ── Converter ─────────────────────────────────────────────
async function convertToPatient() {
const item = dlg.value?.item;
if (!item?.id) return;
if (converting.value) return;
if (item.status === 'converted') {
toast.add({ severity: 'warn', summary: 'Atenção', detail: 'Este cadastro já foi convertido em paciente.', life: 3000 });
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 = await getTenantIdForConversion(item);
if (!tenantId) throw new Error('tenant_id is required');
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 = {
responsible_member_id: responsibleMemberId,
owner_id: ownerId,
nome_completo: cleanStr(fNome(item)),
email_principal: cleanStr(fEmail(item))?.toLowerCase() || null,
email_alternativo: cleanStr(fEmailAlt(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(fNotas(item)),
avatar_url: intakeAvatar
};
Object.keys(patientPayload).forEach((k) => {
if (patientPayload[k] === undefined) delete patientPayload[k];
});
// Repository chamadas (Fase 2 — convertToPatient de-dup).
// patientsRepository.createPatient strip owner_id do payload + sempre injeta auth.uid().
const created = await createPatient(patientPayload);
const patientId = created?.id;
if (!patientId) throw new Error('Falha ao obter ID do paciente criado.');
await markIntakeConverted(item.id, patientId);
toast.add({ severity: 'success', summary: 'Convertido', detail: 'Cadastro convertido em paciente.', life: 2500 });
dlg.value.open = false;
await fetchIntakes();
} catch (err) {
logError('patients.recebidos', 'converter paciente falhou', err);
toast.add({ severity: 'error', summary: 'Falha ao converter', detail: err?.message || 'Não foi possível converter o cadastro.', life: 4500 });
} finally {
converting.value = false;
}
}
const HIGHLIGHT_MS = 24 * 60 * 60 * 1000; // 24h
function isRecent(row) {
if (!row?.created_at) return false;
return Date.now() - new Date(row.created_at).getTime() < HIGHLIGHT_MS;
}
const totals = computed(() => {
const all = rows.value || [];
return {
total: all.length,
nNew: all.filter((r) => r.status === 'new').length,
nConv: all.filter((r) => r.status === 'converted').length,
nRej: all.filter((r) => r.status === 'rejected').length
};
});
// ── Hero sticky ───────────────────────────────────────────
const headerEl = ref(null);
const headerSentinelRef = ref(null);
const headerStuck = ref(false);
let _observer = null;
const recMobileMenuRef = ref(null);
const recSearchDlgOpen = ref(false);
const recMobileMenuItems = computed(() => [
{
label: 'Buscar',
icon: 'pi pi-search',
command: () => {
recSearchDlgOpen.value = true;
}
},
{ separator: true },
{ label: 'Atualizar', icon: 'pi pi-refresh', command: () => fetchIntakes() }
]);
onMounted(() => {
const rootMargin = `${document.querySelector('.l2-main') ? '0px' : '-56px'} 0px 0px 0px`;
_observer = new IntersectionObserver(
([entry]) => {
headerStuck.value = !entry.isIntersecting;
},
{ threshold: 0, rootMargin }
);
if (headerSentinelRef.value) _observer.observe(headerSentinelRef.value);
fetchIntakes();
});
onBeforeUnmount(() => {
_observer?.disconnect();
});
</script>
<template>
<ConfirmDialog />
<!-- Sentinel -->
<div ref="headerSentinelRef" class="h-px" />
<!--
HERO sticky
-->
<section
ref="headerEl"
class="sticky mx-3 md:mx-4 my-3 z-20 overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] px-3 py-2.5 transition-[border-radius] duration-200"
:class="{ 'rounded-tl-none rounded-tr-none': headerStuck }"
:style="{ top: 'var(--layout-sticky-top, 56px)' }"
>
<!-- Blobs -->
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
<div class="absolute w-64 h-64 -top-16 -right-8 rounded-full blur-[60px] bg-emerald-400/10" />
<div class="absolute w-72 h-72 top-0 -left-16 rounded-full blur-[60px] bg-indigo-500/[0.09]" />
</div>
<div class="relative z-1 flex items-center gap-3">
<!-- Brand -->
<div class="flex items-center gap-2 shrink-0">
<div class="grid place-items-center w-9 h-9 rounded-md shrink-0 bg-indigo-500/10 text-indigo-500">
<i class="pi pi-inbox text-base" />
</div>
<div class="min-w-0 hidden lg:block">
<div class="flex items-center gap-2">
<div class="text-[1rem] font-bold tracking-tight text-[var(--text-color)]">Cadastros recebidos</div>
<Tag :value="`${totals.total}`" severity="secondary" />
</div>
<div class="text-[1rem] text-[var(--text-color-secondary)]">Pré-cadastros externos para avaliar e converter em pacientes</div>
</div>
</div>
<!-- Filtros de status + busca desktop -->
<div class="hidden xl:flex flex-1 min-w-0 mx-2 items-center gap-2 flex-wrap">
<Button type="button" size="small" class="!rounded-full" :outlined="statusFilter !== 'new'" :severity="statusFilter === 'new' ? 'info' : 'secondary'" @click="toggleStatusFilter('new')">
<span class="flex items-center gap-1.5"
><i class="pi pi-sparkles text-[1rem]" /> Novos: <b>{{ totals.nNew }}</b></span
>
</Button>
<Button type="button" size="small" class="!rounded-full" :outlined="statusFilter !== 'converted'" :severity="statusFilter === 'converted' ? 'success' : 'secondary'" @click="toggleStatusFilter('converted')">
<span class="flex items-center gap-1.5"
><i class="pi pi-check text-[1rem]" /> Convertidos: <b>{{ totals.nConv }}</b></span
>
</Button>
<Button type="button" size="small" class="!rounded-full" :outlined="statusFilter !== 'rejected'" :severity="statusFilter === 'rejected' ? 'danger' : 'secondary'" @click="toggleStatusFilter('rejected')">
<span class="flex items-center gap-1.5"
><i class="pi pi-times text-[1rem]" /> Rejeitados: <b>{{ totals.nRej }}</b></span
>
</Button>
<Button v-if="statusFilter" type="button" size="small" class="!rounded-full" severity="secondary" outlined icon="pi pi-filter-slash" label="Limpar" @click="statusFilter = ''" />
<InputGroup class="w-64 ml-1">
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
<InputText v-model="q" placeholder="Nome, e-mail ou telefone…" :disabled="loading" />
<Button v-if="q" icon="pi pi-times" severity="secondary" @click="q = ''" />
</InputGroup>
</div>
<!-- Ações desktop -->
<div class="hidden xl:flex items-center gap-1 shrink-0">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" title="Atualizar" @click="fetchIntakes" />
</div>
<!-- Mobile -->
<div class="flex xl:hidden items-center gap-1 shrink-0 ml-auto">
<Button icon="pi pi-search" severity="secondary" outlined class="h-9 w-9 rounded-full" @click="recSearchDlgOpen = true" />
<Button label="Ações" icon="pi pi-ellipsis-v" severity="secondary" size="small" class="rounded-full" @click="(e) => recMobileMenuRef.toggle(e)" />
<Menu ref="recMobileMenuRef" :model="recMobileMenuItems" :popup="true" />
</div>
</div>
</section>
<!-- Dialog busca mobile -->
<Dialog v-model:visible="recSearchDlgOpen" modal :draggable="false" header="Buscar cadastro" class="w-[94vw] max-w-sm">
<div class="pt-1">
<InputGroup>
<InputGroupAddon><i class="pi pi-search" /></InputGroupAddon>
<InputText v-model="q" placeholder="Nome, e-mail ou telefone…" autofocus />
<Button v-if="q" icon="pi pi-times" severity="secondary" @click="q = ''" />
</InputGroup>
</div>
<template #footer>
<Button label="Fechar" severity="secondary" outlined class="rounded-full" @click="recSearchDlgOpen = false" />
</template>
</Dialog>
<!--
QUICK-STATS
-->
<div class="grid grid-cols-2 md:grid-cols-4 gap-2 px-3 md:px-4 mb-3">
<template v-if="loading">
<Skeleton v-for="n in 4" :key="n" height="3.5rem" class="rounded-md" />
</template>
<template v-else>
<div
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] flex-1 min-w-[72px] cursor-pointer select-none transition-[border-color,box-shadow] duration-150 hover:border-indigo-400/40 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="{ 'border-[var(--primary-color,#6366f1)] shadow-[0_0_0_3px_rgba(99,102,241,0.15)]': statusFilter === '' }"
@click="toggleStatusFilter('')"
>
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ totals.total }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 truncate">Total</div>
</div>
<div
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[72px] cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="statusFilter === 'new' ? 'border-sky-500 bg-sky-500/5 shadow-[0_0_0_3px_rgba(14,165,233,0.15)]' : 'border-sky-400/30 bg-sky-500/5 hover:border-sky-400/60'"
@click="toggleStatusFilter('new')"
>
<div class="text-[1.35rem] font-bold leading-none text-sky-500">{{ totals.nNew }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 truncate">Novos</div>
</div>
<div
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[72px] cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="statusFilter === 'converted' ? 'border-green-500 bg-green-500/5 shadow-[0_0_0_3px_rgba(34,197,94,0.15)]' : 'border-green-500/30 bg-green-500/5 hover:border-green-500/50'"
@click="toggleStatusFilter('converted')"
>
<div class="text-[1.35rem] font-bold leading-none text-green-500">{{ totals.nConv }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 truncate">Convertidos</div>
</div>
<div
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border flex-1 min-w-[72px] cursor-pointer select-none transition-[border-color,box-shadow,background] duration-150 hover:shadow-[0_2px_8px_rgba(0,0,0,0.06)]"
:class="statusFilter === 'rejected' ? 'border-red-500 bg-red-500/5 shadow-[0_0_0_3px_rgba(239,68,68,0.15)]' : 'border-red-500/30 bg-red-500/5 hover:border-red-500/50'"
@click="toggleStatusFilter('rejected')"
>
<div class="text-[1.35rem] font-bold leading-none text-red-500">{{ totals.nRej }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] opacity-75 truncate">Rejeitados</div>
</div>
</template>
</div>
<!--
TABELA desktop (md+)
-->
<div class="hidden md:block mx-3 md:mx-4 mb-3">
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] overflow-hidden">
<!-- Cabeçalho da seção -->
<div class="flex items-center justify-between px-4 py-3 border-b border-[var(--surface-border,#e2e8f0)]">
<div class="flex items-center gap-2">
<i class="pi pi-table text-[var(--text-color-secondary)] opacity-60" />
<span class="font-semibold text-[1rem]">Lista de cadastros</span>
<span v-if="statusFilter" class="inline-flex items-center px-1.5 py-0.5 rounded text-[0.65rem] font-semibold bg-indigo-500/10 text-indigo-600"> Filtrado: {{ statusLabel(statusFilter) }} </span>
</div>
<span class="inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-full bg-[var(--primary-color,#6366f1)] text-white text-[1rem] font-bold">
{{ filteredRows.length }}
</span>
</div>
<DataTable :value="filteredRows" :loading="loading" dataKey="id" paginator :rows="10" :rowsPerPageOptions="[10, 20, 50]" stripedRows class="rec-datatable" :rowClass="(r) => (isRecent(r) ? 'row-new-highlight' : '')">
<Column header="Status" style="width: 10rem">
<template #body="{ data }">
<Tag :value="statusLabel(data.status)" :severity="statusSeverity(data.status)" />
</template>
</Column>
<Column header="Paciente">
<template #body="{ data }">
<div class="flex items-center gap-3 min-w-0">
<Avatar v-if="avatarUrl(data)" :image="avatarUrl(data)" shape="circle" />
<Avatar v-else icon="pi pi-user" shape="circle" />
<div class="min-w-0">
<div class="font-medium truncate">{{ fNome(data) || '—' }}</div>
<div class="text-[var(--text-color-secondary)] text-[1rem] truncate">{{ fEmail(data) || '—' }}</div>
</div>
</div>
</template>
</Column>
<Column header="Contato" style="width: 13rem">
<template #body="{ data }">
<div class="text-[1rem]">
<div class="font-medium">{{ fmtPhoneBR(fTel(data)) }}</div>
<div class="text-[var(--text-color-secondary)]">{{ fTelAlt(data) ? fmtPhoneBR(fTelAlt(data)) : '—' }}</div>
</div>
</template>
</Column>
<Column header="Criado em" style="width: 13rem">
<template #body="{ data }">
<span class="text-[var(--text-color-secondary)] text-[1rem]">{{ fmtDate(data.created_at) }}</span>
</template>
</Column>
<Column header="" style="width: 9rem; text-align: right">
<template #body="{ data }">
<Button icon="pi pi-eye" label="Ver" severity="secondary" outlined @click="openDetails(data)" />
</template>
</Column>
<template #empty>
<div class="py-10 text-center">
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-md bg-indigo-500/10 text-indigo-500">
<i class="pi pi-inbox text-xl" />
</div>
<div class="font-semibold text-[var(--text-color)]">Nenhum cadastro encontrado</div>
<div class="mt-1 text-[1rem] text-[var(--text-color-secondary)]">
{{ q || statusFilter ? 'Tente limpar os filtros ou mudar o termo de busca.' : 'Ainda não há cadastros recebidos.' }}
</div>
<div v-if="q || statusFilter" class="mt-4 flex justify-center gap-2">
<Button
severity="secondary"
outlined
icon="pi pi-filter-slash"
label="Limpar filtros"
@click="
q = '';
statusFilter = '';
"
/>
</div>
</div>
</template>
</DataTable>
</div>
</div>
<!--
CARDS mobile (<md)
-->
<div class="md:hidden mx-3 mb-5">
<div v-if="loading" class="flex flex-col gap-2.5">
<div v-for="n in 5" :key="n" class="rounded-md border border-[var(--surface-border)] bg-[var(--surface-card)] p-3.5 flex flex-col gap-3">
<div class="flex items-center gap-3">
<Skeleton shape="circle" size="2.5rem" />
<div class="flex flex-col gap-1.5 flex-1">
<Skeleton width="55%" height="13px" />
<Skeleton width="40%" height="11px" />
</div>
<Skeleton width="60px" height="22px" border-radius="999px" />
</div>
<div class="flex items-center justify-between">
<Skeleton width="30%" height="11px" />
<Skeleton width="60px" height="28px" border-radius="999px" />
</div>
</div>
</div>
<div v-else-if="filteredRows.length === 0" class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] py-10 text-center">
<div class="mx-auto mb-3 grid h-12 w-12 place-items-center rounded-md bg-indigo-500/10 text-indigo-500">
<i class="pi pi-inbox text-xl" />
</div>
<div class="font-semibold">Nenhum cadastro encontrado</div>
<div class="mt-1 text-[1rem] text-[var(--text-color-secondary)]">
{{ q || statusFilter ? 'Tente limpar os filtros ou mudar o termo de busca.' : 'Ainda não há cadastros recebidos.' }}
</div>
<div v-if="q || statusFilter" class="mt-4 flex justify-center gap-2">
<Button
severity="secondary"
outlined
icon="pi pi-filter-slash"
label="Limpar filtros"
@click="
q = '';
statusFilter = '';
"
/>
</div>
</div>
<div v-else class="flex flex-col gap-2.5">
<div
v-for="row in filteredRows"
:key="row.id"
class="rounded-md border p-3.5 transition-colors"
:class="isRecent(row) ? 'border-emerald-300 bg-emerald-50 dark:bg-emerald-950/30 dark:border-emerald-800' : 'border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]'"
>
<div class="flex items-center gap-3">
<Avatar v-if="avatarUrl(row)" :image="avatarUrl(row)" shape="circle" />
<Avatar v-else icon="pi pi-user" shape="circle" />
<div class="flex-1 min-w-0">
<div class="font-semibold truncate">{{ fNome(row) || '—' }}</div>
<div class="text-[1rem] text-[var(--text-color-secondary)] truncate">{{ fEmail(row) || '—' }}</div>
</div>
<Tag :value="statusLabel(row.status)" :severity="statusSeverity(row.status)" />
</div>
<div class="mt-2.5 flex items-center justify-between gap-2">
<div class="text-[1rem] text-[var(--text-color-secondary)] flex flex-col gap-0.5">
<span>{{ fmtPhoneBR(fTel(row)) }}</span>
<span>{{ fmtDate(row.created_at) }}</span>
</div>
<Button icon="pi pi-eye" label="Ver" severity="secondary" outlined size="small" @click="openDetails(row)" />
</div>
</div>
</div>
</div>
<div class="px-3 md:px-4 pb-3">
<LoadedPhraseBlock v-if="hasLoaded" />
</div>
<!--
MODAL detalhe
-->
<Dialog v-model:visible="dlg.open" modal :header="null" :style="{ width: 'min(940px, 96vw)' }" :contentStyle="{ padding: 0 }" :draggable="false" @hide="closeDlg">
<div v-if="dlg.item" class="relative">
<div class="max-h-[70vh] overflow-auto p-5 bg-[var(--surface-ground,#f8fafc)]">
<!-- Topo: avatar + nome + status -->
<div class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] p-4 mb-4">
<div class="flex flex-col items-center text-center gap-3">
<div class="relative">
<div class="absolute inset-0 blur-2xl opacity-30 rounded-full bg-slate-300" />
<div class="relative">
<Avatar v-if="dlgAvatarUrl" :image="dlgAvatarUrl" alt="avatar" shape="circle" size="xlarge" />
<Avatar v-else icon="pi pi-user" shape="circle" size="xlarge" />
</div>
</div>
<div class="min-w-0">
<div class="text-xl font-semibold text-[var(--text-color)] truncate">{{ fNome(dlg.item) || '—' }}</div>
<div class="text-[var(--text-color-secondary)] text-[1rem] truncate">{{ fEmail(dlg.item) || '—' }} · {{ fmtPhoneBR(fTel(dlg.item)) }}</div>
</div>
<div class="flex flex-wrap justify-center gap-2">
<Tag :value="statusLabel(dlg.item.status)" :severity="statusSeverity(dlg.item.status)" />
<Tag :value="dlg.item.consent ? 'Consentimento OK' : 'Sem consentimento'" :severity="dlg.item.consent ? 'success' : 'danger'" />
<Tag :value="`Criado: ${fmtDate(dlg.item.created_at)}`" severity="secondary" />
</div>
</div>
</div>
<!-- Seções de dados -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-3.5">
<div v-for="(sec, sidx) in intakeSections" :key="sidx" class="rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] p-4">
<div class="font-semibold text-[var(--text-color)] text-[0.88rem] mb-3">{{ sec.title }}</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
<div v-for="(r, ridx) in sec.rows" :key="ridx" class="min-w-0">
<div class="text-[0.68rem] text-[var(--text-color-secondary)] opacity-60 mb-0.5 uppercase tracking-[0.04em]">{{ r.label }}</div>
<div class="text-[1rem] text-[var(--text-color)]" :class="r.pre ? 'whitespace-pre-wrap leading-relaxed' : 'truncate'">{{ r.value }}</div>
</div>
</div>
</div>
</div>
<!-- Rejeição -->
<div class="mt-4 rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] p-4">
<div class="flex items-center justify-between gap-2 flex-wrap mb-3">
<div class="font-semibold text-[var(--text-color)] text-[0.88rem]">Rejeição</div>
<Tag :value="dlg.item.status === 'rejected' ? 'Este cadastro já foi rejeitado' : 'Opcional'" :severity="dlg.item.status === 'rejected' ? 'danger' : 'secondary'" />
</div>
<label class="block text-[1rem] text-[var(--text-color-secondary)] mb-2">Motivo (anotação interna)</label>
<Textarea v-model="dlg.reject_note" autoResize rows="2" class="w-full" :disabled="dlg.saving || converting" placeholder="Ex.: dados incompletos, pediu para não seguir, duplicado…" />
</div>
<div class="h-24" />
</div>
<!-- Ações fixas no rodapé -->
<div class="sticky bottom-0 z-10 border-t border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)]">
<div class="px-5 py-4 flex flex-col sm:flex-row gap-3 sm:items-center sm:justify-between">
<div class="flex items-center gap-2">
<Tag :value="statusLabel(dlg.item.status)" :severity="statusSeverity(dlg.item.status)" />
</div>
<div class="flex gap-2 justify-end flex-wrap">
<Button label="Rejeitar" icon="pi pi-times" severity="danger" outlined :disabled="dlg.saving || dlg.item.status === 'rejected' || converting" @click="markRejected" />
<Button label="Converter" icon="pi pi-check" severity="success" :loading="converting" :disabled="dlg.item.status === 'converted' || dlg.saving || converting" @click="convertToPatient" />
<Button label="Fechar" icon="pi pi-times-circle" severity="secondary" outlined :disabled="dlg.saving || converting" @click="closeDlg" />
</div>
</div>
</div>
</div>
</Dialog>
</template>