f17e9ee786
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>
845 lines
43 KiB
Vue
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>
|