957e912a7f
Sprints B (05-03) e C (05-04) acumulados: - NotificationDrawer/Item redesign (visual mais limpo, ações inline) - Dock pins compose (useMelissaDockPins) + cache store global (melissaCacheStore) - MelissaAgenda: timeline FullCalendar parity + cards resumo, histórico card com useMelissaAgendaHistorico, MelissaEventoPanel ajustado - useFeriados: cache opt-in pra evitar fetch redundante de feriados - PatientProntuario: aba Visão Geral nova; PatientConversationsTab polish - AgendaClinicMosaic / AgendaTerapeutaPage / useAgendaSettings: ajustes de paridade com Melissa - DocumentsListPage: pequenos ajustes - DB migration 20260504000001: fix do trigger pra status 'excluido' nas cancel_notifications Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1176 lines
73 KiB
Plaintext
1176 lines
73 KiB
Plaintext
<!--
|
||
|--------------------------------------------------------------------------
|
||
| Agência PSI
|
||
|--------------------------------------------------------------------------
|
||
| Criado e desenvolvido por Leonardo Nohama
|
||
|
|
||
| Tecnologia aplicada à escuta.
|
||
| Estrutura para o cuidado.
|
||
|
|
||
| Arquivo: src/features/patients/prontuario/PatientProntuario.vue
|
||
| Data: 2026
|
||
| Local: São Carlos/SP — Brasil
|
||
|--------------------------------------------------------------------------
|
||
| © 2026 — Todos os direitos reservados
|
||
|--------------------------------------------------------------------------
|
||
-->
|
||
<script setup>
|
||
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue';
|
||
|
||
import Chip from 'primevue/chip';
|
||
import Accordion from 'primevue/accordion';
|
||
import AccordionPanel from 'primevue/accordionpanel';
|
||
import AccordionHeader from 'primevue/accordionheader';
|
||
import AccordionContent from 'primevue/accordioncontent';
|
||
import Popover from 'primevue/popover';
|
||
|
||
import { useToast } from 'primevue/usetoast';
|
||
import { supabase } from '@/lib/supabase/client';
|
||
|
||
import DocumentsListPage from '@/features/documents/DocumentsListPage.vue';
|
||
import PatientConversationsTab from './PatientConversationsTab.vue';
|
||
|
||
// ── PROPS / EMITS ──────────────────────────────────────────────
|
||
const props = defineProps({
|
||
modelValue: { type: Boolean, default: false },
|
||
patient: { type: Object, default: () => ({}) },
|
||
});
|
||
const emit = defineEmits(['update:modelValue', 'close', 'edit']);
|
||
|
||
const toast = useToast();
|
||
|
||
const model = computed({
|
||
get: () => props.modelValue,
|
||
set: (v) => emit('update:modelValue', v),
|
||
});
|
||
|
||
// ── UTILS ──────────────────────────────────────────────────────
|
||
function isEmpty(v) {
|
||
if (v === null || v === undefined) return true;
|
||
return !String(v).trim();
|
||
}
|
||
function dash(v) {
|
||
const s = String(v ?? '').trim();
|
||
return s || '—';
|
||
}
|
||
function pick(obj, keys = []) {
|
||
for (const k of keys) {
|
||
const v = obj?.[k];
|
||
if (!isEmpty(v)) return v;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ── TABS PRINCIPAIS ────────────────────────────────────────────
|
||
const activeTab = ref(0);
|
||
const mainTabs = [
|
||
{ label: 'Perfil', icon: 'pi pi-user' },
|
||
{ label: 'Prontuário', icon: 'pi pi-file-edit' },
|
||
{ label: 'Agenda', icon: 'pi pi-calendar' },
|
||
{ label: 'Financeiro', icon: 'pi pi-wallet' },
|
||
{ label: 'Documentos', icon: 'pi pi-folder' },
|
||
{ label: 'Conversas', icon: 'pi pi-whatsapp' },
|
||
];
|
||
|
||
// ── ACCORDION DA ABA PERFIL ────────────────────────────────────
|
||
const accordionValues = ['0', '1', '2', '3', '4', '5'];
|
||
const activeAccValues = ref(['0']);
|
||
const activeAccValue = computed(() => activeAccValues.value?.[0] ?? null);
|
||
|
||
const panelHeaderRefs = ref([]);
|
||
function setPanelHeaderRef(el, idx) {
|
||
if (!el) return;
|
||
panelHeaderRefs.value[idx] = el;
|
||
}
|
||
|
||
const allOpen = computed(() =>
|
||
accordionValues.every((v) => activeAccValues.value.includes(v))
|
||
);
|
||
function toggleAllAccordions() {
|
||
activeAccValues.value = allOpen.value ? [] : [...accordionValues];
|
||
}
|
||
|
||
async function openPanel(i) {
|
||
activeTab.value = 0;
|
||
const v = String(i);
|
||
activeAccValues.value = [v];
|
||
await nextTick();
|
||
const headerRef = panelHeaderRefs.value?.[i];
|
||
const el = headerRef?.$el ?? headerRef;
|
||
if (el && typeof el.scrollIntoView === 'function') {
|
||
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
}
|
||
}
|
||
|
||
const profileNavItems = [
|
||
{ value: '0', label: 'Informações Pessoais', icon: 'pi pi-pencil' },
|
||
{ value: '1', label: 'Endereço', icon: 'pi pi-map-marker' },
|
||
{ value: '2', label: 'Dados Adicionais', icon: 'pi pi-tags' },
|
||
{ value: '3', label: 'Responsável', icon: 'pi pi-users' },
|
||
{ value: '4', label: 'Anotações', icon: 'pi pi-file-edit' },
|
||
{ value: '5', label: 'Sessões', icon: 'pi pi-calendar' },
|
||
];
|
||
|
||
const navPopover = ref(null);
|
||
function toggleNav(event) { navPopover.value?.toggle(event); }
|
||
function selectNav(item) {
|
||
openPanel(Number(item.value));
|
||
navPopover.value?.hide();
|
||
}
|
||
const selectedNav = computed(() =>
|
||
profileNavItems.find((i) => i.value === activeAccValue.value) || null
|
||
);
|
||
|
||
// Responsivo
|
||
const isCompact = ref(false);
|
||
let mql = null, mqlHandler = null;
|
||
function syncCompact() { isCompact.value = !!mql?.matches; }
|
||
onMounted(() => {
|
||
mql = window.matchMedia('(max-width: 1199px)');
|
||
mqlHandler = () => syncCompact();
|
||
mql.addEventListener?.('change', mqlHandler);
|
||
mql.addListener?.(mqlHandler);
|
||
syncCompact();
|
||
});
|
||
onBeforeUnmount(() => {
|
||
mql?.removeEventListener?.('change', mqlHandler);
|
||
mql?.removeListener?.(mqlHandler);
|
||
});
|
||
|
||
// ── DATA LOAD ──────────────────────────────────────────────────
|
||
const loading = ref(false);
|
||
const loadError = ref('');
|
||
const patientFull = ref(null);
|
||
const groups = ref([]);
|
||
const tags = ref([]);
|
||
const sessions = ref([]);
|
||
const sessionsLoading = ref(false);
|
||
|
||
const patientData = computed(() => patientFull.value || props.patient || {});
|
||
|
||
// Avatar
|
||
const avatarUrl = computed(() =>
|
||
patientData.value?.avatar_url || patientData.value?.avatar || null
|
||
);
|
||
|
||
// Grupos
|
||
const groupNames = computed(() =>
|
||
(groups.value || []).map((g) => g?.name).filter(Boolean)
|
||
);
|
||
const groupLabel = computed(() => {
|
||
const n = groupNames.value.length;
|
||
if (n === 0) return '—';
|
||
return groupNames.value.join(', ');
|
||
});
|
||
const groupCountLabel = computed(() =>
|
||
groupNames.value.length <= 1 ? 'Grupo' : 'Grupos'
|
||
);
|
||
|
||
// ── COMPUTED FIELDS ────────────────────────────────────────────
|
||
const birthValue = computed(() => pick(patientData.value, ['data_nascimento', 'birth_date']));
|
||
const nomeCompleto = computed(() => pick(patientData.value, ['nome_completo', 'name']));
|
||
const telefone = computed(() => pick(patientData.value, ['telefone', 'phone']));
|
||
const emailPrincipal = computed(() => pick(patientData.value, ['email_principal', 'email']));
|
||
const emailAlternativo = computed(() => pick(patientData.value, ['email_alternativo', 'email_alt', 'emailAlt']));
|
||
const telefoneAlternativo = computed(() => pick(patientData.value, ['telefone_alternativo', 'phone_alt', 'phoneAlt']));
|
||
const genero = computed(() => pick(patientData.value, ['genero', 'gender']));
|
||
const estadoCivil = computed(() => pick(patientData.value, ['estado_civil', 'marital_status']));
|
||
const naturalidade = computed(() => pick(patientData.value, ['naturalidade', 'birthplace', 'place_of_birth']));
|
||
const observacoes = computed(() => pick(patientData.value, ['observacoes', 'notes_short']));
|
||
const ondeNosConheceu = computed(() => pick(patientData.value, ['onde_nos_conheceu', 'lead_source']));
|
||
const encaminhadoPor = computed(() => pick(patientData.value, ['encaminhado_por', 'referred_by']));
|
||
const statusPaciente = computed(() => pick(patientData.value, ['status']));
|
||
const convenio = computed(() => pick(patientData.value, ['convenio']));
|
||
const patientScope = computed(() => pick(patientData.value, ['patient_scope']));
|
||
const riscoElevado = computed(() => !!patientData.value?.risco_elevado);
|
||
|
||
const cep = computed(() => pick(patientData.value, ['cep', 'postal_code']));
|
||
const pais = computed(() => pick(patientData.value, ['pais', 'country']));
|
||
const cidade = computed(() => pick(patientData.value, ['cidade', 'city']));
|
||
const estado = computed(() => pick(patientData.value, ['estado', 'state']));
|
||
const endereco = computed(() => pick(patientData.value, ['endereco', 'address_line']));
|
||
const numero = computed(() => pick(patientData.value, ['numero', 'address_number']));
|
||
const bairro = computed(() => pick(patientData.value, ['bairro', 'neighborhood']));
|
||
const complemento = computed(() => pick(patientData.value, ['complemento', 'address_complement']));
|
||
|
||
const escolaridade = computed(() => pick(patientData.value, ['escolaridade', 'education', 'education_level']));
|
||
const profissao = computed(() => pick(patientData.value, ['profissao', 'profession']));
|
||
const nomeParente = computed(() => pick(patientData.value, ['nome_parente', 'relative_name']));
|
||
const grauParentesco = computed(() => pick(patientData.value, ['grau_parentesco', 'relative_relation']));
|
||
const telefoneParente = computed(() => pick(patientData.value, ['telefone_parente', 'relative_phone']));
|
||
|
||
const nomeResponsavel = computed(() => pick(patientData.value, ['nome_responsavel', 'guardian_name']));
|
||
const cpfResponsavel = computed(() => pick(patientData.value, ['cpf_responsavel', 'guardian_cpf']));
|
||
const telefoneResponsavel = computed(() => pick(patientData.value, ['telefone_responsavel', 'guardian_phone']));
|
||
const observacaoResponsavel = computed(() => pick(patientData.value, ['observacao_responsavel', 'guardian_note']));
|
||
const notasInternas = computed(() => pick(patientData.value, ['notas_internas', 'notes']));
|
||
|
||
// Métricas (lidas do patientData ou fallback null)
|
||
const totalSessoes = computed(() => patientData.value?.total_sessoes ?? null);
|
||
const comparecimentoPct = computed(() => patientData.value?.comparecimento_pct ?? null);
|
||
const ltvTotal = computed(() => patientData.value?.ltv_total ?? null);
|
||
const diasUltimaSessao = computed(() => patientData.value?.dias_ultima_sessao ?? null);
|
||
|
||
// ── FORMATTERS ─────────────────────────────────────────────────
|
||
function nameInitials(name) {
|
||
if (!name) return '?';
|
||
return String(name).split(' ').filter(Boolean).slice(0, 2)
|
||
.map((w) => w[0].toUpperCase()).join('');
|
||
}
|
||
const avatarInitials = computed(() => nameInitials(nomeCompleto.value));
|
||
|
||
function onlyDigits(v) { return String(v ?? '').replace(/\D/g, ''); }
|
||
|
||
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 ?? '').toUpperCase().replace(/[^0-9X]/g, '');
|
||
if (!s) return '—';
|
||
if (s.length === 9) return `${s.slice(0,2)}.${s.slice(2,5)}.${s.slice(5,8)}-${s.slice(8)}`;
|
||
if (s.length === 8) return `${s.slice(0,2)}.${s.slice(2,5)}.${s.slice(5,8)}`;
|
||
return s;
|
||
}
|
||
|
||
function parseDateLoose(v) {
|
||
if (!v) return null;
|
||
const s = String(v).trim();
|
||
if (/^\d{4}-\d{2}-\d{2}/.test(s)) {
|
||
const d = new Date(s.slice(0, 10));
|
||
return isNaN(d) ? null : d;
|
||
}
|
||
let m = s.match(/^(\d{2})[/-](\d{2})[/-](\d{4})$/);
|
||
if (m) {
|
||
const d = new Date(Number(m[3]), Number(m[2]) - 1, Number(m[1]));
|
||
return isNaN(d) ? null : d;
|
||
}
|
||
const d = new Date(s);
|
||
return isNaN(d) ? null : d;
|
||
}
|
||
|
||
function fmtDateBR(v) {
|
||
const d = parseDateLoose(v);
|
||
if (!d) return v ? dash(v) : '—';
|
||
return `${String(d.getDate()).padStart(2,'0')}/${String(d.getMonth()+1).padStart(2,'0')}/${d.getFullYear()}`;
|
||
}
|
||
|
||
function calcAge(v) {
|
||
const d = parseDateLoose(v);
|
||
if (!d) return null;
|
||
const now = new Date();
|
||
let age = now.getFullYear() - d.getFullYear();
|
||
const m = now.getMonth() - d.getMonth();
|
||
if (m < 0 || (m === 0 && now.getDate() < d.getDate())) age--;
|
||
return age;
|
||
}
|
||
|
||
const ageLabel = computed(() => {
|
||
const age = calcAge(birthValue.value);
|
||
return age == null ? '—' : `${age} anos`;
|
||
});
|
||
|
||
function fmtPhoneMobile(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 fmtGender(v) {
|
||
const s = String(v ?? '').trim();
|
||
if (!s) return '—';
|
||
const x = s.toLowerCase();
|
||
if (['m','masc','masculino','male','man','homem'].includes(x)) return 'Masculino';
|
||
if (['f','fem','feminino','female','woman','mulher'].includes(x)) return 'Feminino';
|
||
if (['nb','nao-binario','não-binário','nonbinary','non-binary'].includes(x)) return 'Não-binário';
|
||
if (['outro','other'].includes(x)) return 'Outro';
|
||
return s;
|
||
}
|
||
|
||
function fmtMarital(v) {
|
||
const s = String(v ?? '').trim();
|
||
if (!s) return '—';
|
||
const x = s.toLowerCase();
|
||
if (['solteiro','solteira','single'].includes(x)) return 'Solteiro(a)';
|
||
if (['casado','casada','married'].includes(x)) return 'Casado(a)';
|
||
if (['divorciado','divorciada','divorced'].includes(x)) return 'Divorciado(a)';
|
||
if (['viuvo','viúva','viuvo(a)','widowed'].includes(x)) return 'Viúvo(a)';
|
||
if (['uniao estavel','união estável','civil union'].includes(x)) return 'União estável';
|
||
return s;
|
||
}
|
||
|
||
function fmtCurrency(v) {
|
||
if (!v && v !== 0) return '—';
|
||
return `R$ ${Number(v).toLocaleString('pt-BR')}`;
|
||
}
|
||
|
||
function fmtDateTimeBR(iso) {
|
||
if (!iso) return '—';
|
||
const d = new Date(iso);
|
||
if (isNaN(d)) return iso;
|
||
const dd = String(d.getDate()).padStart(2,'0');
|
||
const mm = String(d.getMonth()+1).padStart(2,'0');
|
||
const hh = String(d.getHours()).padStart(2,'0');
|
||
const mi = String(d.getMinutes()).padStart(2,'0');
|
||
return `${dd}/${mm}/${d.getFullYear()} ${hh}:${mi}`;
|
||
}
|
||
|
||
function sessionDuration(inicio, fim) {
|
||
if (!inicio || !fim) return null;
|
||
const diff = new Date(fim) - new Date(inicio);
|
||
if (diff <= 0) return null;
|
||
const min = Math.round(diff / 60000);
|
||
if (min < 60) return `${min} min`;
|
||
const h = Math.floor(min / 60);
|
||
const m = min % 60;
|
||
return m ? `${h}h ${m}min` : `${h}h`;
|
||
}
|
||
|
||
// ── TAG COLORS ─────────────────────────────────────────────────
|
||
function normalizeHexColor(c) {
|
||
const s = String(c ?? '').trim();
|
||
if (!s) return '';
|
||
if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(s)) return s;
|
||
if (/^([0-9a-f]{3}|[0-9a-f]{6})$/i.test(s)) return `#${s}`;
|
||
return s;
|
||
}
|
||
function hexToRgb(hex) {
|
||
const h = String(hex || '').replace('#','').trim();
|
||
if (!/^([0-9a-f]{3}|[0-9a-f]{6})$/i.test(h)) return null;
|
||
const full = h.length === 3 ? h.split('').map(ch => ch+ch).join('') : h;
|
||
const r = parseInt(full.slice(0,2), 16);
|
||
const g = parseInt(full.slice(2,4), 16);
|
||
const b = parseInt(full.slice(4,6), 16);
|
||
if ([r,g,b].some(n => isNaN(n))) return null;
|
||
return { r, g, b };
|
||
}
|
||
function relativeLuminance({ r, g, b }) {
|
||
const srgb = [r,g,b].map(v => v/255).map(c => c <= 0.03928 ? c/12.92 : Math.pow((c+0.055)/1.055, 2.4));
|
||
return 0.2126*srgb[0] + 0.7152*srgb[1] + 0.0722*srgb[2];
|
||
}
|
||
function bestTextColor(bg) {
|
||
const rgb = hexToRgb(normalizeHexColor(bg));
|
||
if (!rgb) return '#0f172a';
|
||
return relativeLuminance(rgb) < 0.45 ? '#ffffff' : '#0f172a';
|
||
}
|
||
function tagStyle(t) {
|
||
const bg = normalizeHexColor(t?.color || t?.cor);
|
||
if (!bg) return {};
|
||
return { background: bg, color: bestTextColor(bg), borderColor: 'transparent' };
|
||
}
|
||
|
||
// ── SESSION STATUS ─────────────────────────────────────────────
|
||
const STATUS_LABEL = {
|
||
agendado: 'Agendado', realizado: 'Realizado', faltou: 'Faltou',
|
||
cancelado: 'Cancelado', remarcado: 'Remarcado', bloqueado: 'Bloqueado',
|
||
};
|
||
const STATUS_SEVERITY = {
|
||
agendado: 'info', realizado: 'success', faltou: 'danger',
|
||
cancelado: 'warn', remarcado: 'secondary', bloqueado: 'secondary',
|
||
};
|
||
|
||
// ── SUPABASE ───────────────────────────────────────────────────
|
||
async function loadSessions(patientId) {
|
||
sessionsLoading.value = true;
|
||
sessions.value = [];
|
||
try {
|
||
const { data, error } = await supabase
|
||
.from('agenda_eventos')
|
||
.select('id, inicio_em, fim_em, status, modalidade, tipo, titulo, titulo_custom, observacoes')
|
||
.eq('patient_id', patientId)
|
||
.order('inicio_em', { ascending: false })
|
||
.limit(100);
|
||
if (error) throw error;
|
||
sessions.value = data || [];
|
||
} catch { sessions.value = []; }
|
||
finally { sessionsLoading.value = false; }
|
||
}
|
||
|
||
async function getPatientById(id) {
|
||
const { data, error } = await supabase.from('patients').select('*').eq('id', id).maybeSingle();
|
||
if (error) throw error;
|
||
return data;
|
||
}
|
||
|
||
async function getPatientRelations(id) {
|
||
const { data: g, error: ge } = await supabase.from('patient_group_patient').select('patient_group_id').eq('patient_id', id);
|
||
if (ge) throw ge;
|
||
const { data: t, error: te } = await supabase.from('patient_patient_tag').select('tag_id').eq('patient_id', id);
|
||
if (te) throw te;
|
||
return {
|
||
groupIds: (g || []).map(x => x.patient_group_id).filter(Boolean),
|
||
tagIds: (t || []).map(x => x.tag_id).filter(Boolean),
|
||
};
|
||
}
|
||
|
||
async function getGroupsByIds(ids) {
|
||
if (!ids?.length) return [];
|
||
const { data, error } = await supabase.from('patient_groups').select('id, nome').in('id', ids).order('nome', { ascending: true });
|
||
if (error) throw error;
|
||
return (data || []).map(g => ({ id: g.id, name: g.nome }));
|
||
}
|
||
|
||
async function getTagsByIds(ids) {
|
||
if (!ids?.length) return [];
|
||
const { data, error } = await supabase.from('patient_tags').select('id, nome, cor').in('id', ids).order('nome', { ascending: true });
|
||
if (error) throw error;
|
||
return (data || []).map(t => ({ id: t.id, name: t.nome, color: t.cor }));
|
||
}
|
||
|
||
async function loadDetail(id) {
|
||
loadError.value = '';
|
||
loading.value = true;
|
||
patientFull.value = null;
|
||
groups.value = [];
|
||
tags.value = [];
|
||
try {
|
||
const [p, rel] = await Promise.all([getPatientById(id), getPatientRelations(id)]);
|
||
if (!p) throw new Error('Paciente não retornou dados (RLS bloqueando ou ID não existe no banco).');
|
||
patientFull.value = p;
|
||
const [g, t] = await Promise.all([
|
||
getGroupsByIds(rel.groupIds || []),
|
||
getTagsByIds(rel.tagIds || []),
|
||
loadSessions(id),
|
||
]);
|
||
groups.value = g;
|
||
tags.value = t;
|
||
} catch (e) {
|
||
loadError.value = e?.message || 'Falha ao buscar dados no Supabase.';
|
||
toast.add({ severity: 'error', summary: 'Erro ao carregar', detail: loadError.value, life: 4500 });
|
||
} finally {
|
||
loading.value = false;
|
||
}
|
||
}
|
||
|
||
watch(
|
||
[() => props.modelValue, () => props.patient?.id],
|
||
async ([open, id], [prevOpen, prevId]) => {
|
||
if (!open || !id) return;
|
||
if (open === prevOpen && id === prevId) return;
|
||
activeTab.value = 0;
|
||
activeAccValues.value = ['0'];
|
||
await loadDetail(id);
|
||
},
|
||
{ immediate: true }
|
||
);
|
||
|
||
// ── AÇÕES ──────────────────────────────────────────────────────
|
||
function close() {
|
||
model.value = false;
|
||
emit('close');
|
||
}
|
||
function editPatient() {
|
||
const id = patientData.value?.id;
|
||
if (!id) return;
|
||
close();
|
||
emit('edit', String(id));
|
||
}
|
||
|
||
async function copyResumo() {
|
||
const txt = `Paciente: ${dash(nomeCompleto.value)}
|
||
Idade: ${ageLabel.value}
|
||
${groupCountLabel.value}: ${groupLabel.value}
|
||
Telefone: ${fmtPhoneMobile(telefone.value)}
|
||
Email: ${dash(emailPrincipal.value)}
|
||
CPF: ${fmtCPF(patientData.value?.cpf)}
|
||
RG: ${fmtRG(patientData.value?.rg)}
|
||
Nascimento: ${fmtDateBR(birthValue.value)}
|
||
Tags: ${(tags.value || []).map(t => t.name).filter(Boolean).join(', ') || '—'}`;
|
||
try {
|
||
await navigator.clipboard.writeText(txt);
|
||
toast.add({ severity: 'success', summary: 'Copiado', detail: 'Resumo copiado para a área de transferência.', life: 2200 });
|
||
} catch {
|
||
toast.add({ severity: 'error', summary: 'Falha', detail: 'Não foi possível copiar.', life: 3000 });
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<Dialog
|
||
v-model:visible="model"
|
||
modal
|
||
:draggable="false"
|
||
maximizable
|
||
:style="{ width: '96vw', maxWidth: '1400px' }"
|
||
:contentStyle="{ padding: 0 }"
|
||
pt:mask:class="backdrop-blur-sm"
|
||
pt:header:class="border-b border-[var(--surface-border)] px-4 py-3"
|
||
pt:title:class="text-[0.95rem] font-bold text-[var(--text-color)] truncate"
|
||
@hide="close"
|
||
>
|
||
<!-- ── HEADER DO DIALOG ────────────────────────────────── -->
|
||
<template #header>
|
||
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||
<!-- Avatar mini -->
|
||
<div v-if="avatarUrl"
|
||
class="w-8 h-8 rounded-full overflow-hidden shrink-0 border border-[var(--surface-border)]">
|
||
<img :src="avatarUrl" class="w-full h-full object-cover" />
|
||
</div>
|
||
<div v-else
|
||
class="w-8 h-8 rounded-full bg-indigo-100 flex items-center justify-center shrink-0">
|
||
<span class="text-[0.68rem] font-bold text-indigo-700">{{ avatarInitials }}</span>
|
||
</div>
|
||
|
||
<!-- Nome + sub-linha -->
|
||
<div class="min-w-0 flex-1">
|
||
<p class="text-[0.92rem] font-bold text-[var(--text-color)] truncate leading-tight">
|
||
{{ dash(nomeCompleto) }}
|
||
</p>
|
||
<div class="flex items-center gap-2 flex-wrap mt-0.5">
|
||
<span class="text-[0.72rem] text-[var(--text-color-secondary)]">
|
||
{{ ageLabel }}<template v-if="patientData.pronomes"> · {{ patientData.pronomes }}</template>
|
||
</span>
|
||
<!-- Badge risco elevado no header -->
|
||
<span v-if="riscoElevado"
|
||
class="inline-flex items-center gap-1 text-[0.65rem] font-semibold
|
||
text-red-600 bg-red-50 border border-red-200 px-1.5 py-0.5 rounded-full">
|
||
<i class="pi pi-exclamation-circle text-[0.6rem]" />
|
||
Risco elevado
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Botão + Sessão no header -->
|
||
<Button label="+ Sessão" size="small" class="shrink-0 mr-2" />
|
||
</div>
|
||
</template>
|
||
|
||
<!-- ══ CONTEÚDO ════════════════════════════════════════════ -->
|
||
<div class="bg-[var(--surface-ground)] min-h-[60vh]">
|
||
|
||
<!-- Loading -->
|
||
<div v-if="loading"
|
||
class="flex items-center justify-center py-20 gap-2 text-sm text-[var(--text-color-secondary)]">
|
||
<i class="pi pi-spin pi-spinner" /> Carregando…
|
||
</div>
|
||
|
||
<!-- Erro -->
|
||
<div v-else-if="loadError" class="p-4">
|
||
<div class="flex items-start gap-3 rounded-xl border border-red-200 bg-red-50 px-4 py-3">
|
||
<i class="pi pi-exclamation-circle text-red-500 mt-0.5 shrink-0" />
|
||
<div>
|
||
<p class="text-sm font-semibold text-red-700">Falha ao carregar</p>
|
||
<p class="text-xs text-red-500 mt-0.5">{{ loadError }}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-else class="grid grid-cols-1 xl:grid-cols-[240px_1fr] gap-3 p-3 md:p-4">
|
||
|
||
<!-- ══ SIDEBAR ══════════════════════════════════════ -->
|
||
<aside class="rounded-xl border border-[var(--surface-border)]
|
||
bg-[var(--surface-card)] p-4 shadow-sm
|
||
xl:sticky xl:top-2 xl:self-start space-y-0">
|
||
|
||
<!-- Banner risco elevado (sidebar) -->
|
||
<div v-if="riscoElevado"
|
||
class="flex items-start gap-2 rounded-lg border border-red-200
|
||
bg-red-50 px-3 py-2 mb-4">
|
||
<i class="pi pi-exclamation-circle text-red-500 text-sm mt-0.5 shrink-0" />
|
||
<p class="text-[0.73rem] font-semibold text-red-700 leading-snug">
|
||
Risco elevado sinalizado
|
||
</p>
|
||
</div>
|
||
|
||
<!-- Avatar + nome + badges -->
|
||
<div class="flex items-center gap-3 pb-4 mb-4
|
||
border-b border-[var(--surface-border)]
|
||
xl:flex-col xl:items-center xl:gap-2 xl:text-center">
|
||
<!-- Avatar -->
|
||
<div class="shrink-0 w-16 h-16 xl:w-20 xl:h-20 rounded-full overflow-hidden">
|
||
<img v-if="avatarUrl" :src="avatarUrl" alt="avatar"
|
||
class="w-full h-full object-cover" />
|
||
<div v-else class="w-full h-full bg-indigo-100 flex items-center justify-center">
|
||
<span class="text-xl font-bold text-indigo-700 tracking-tight">
|
||
{{ avatarInitials }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<!-- Info -->
|
||
<div class="flex-1 min-w-0 xl:text-center">
|
||
<p class="text-sm font-bold text-[var(--text-color)] truncate">
|
||
{{ dash(nomeCompleto) }}
|
||
</p>
|
||
<p class="text-xs text-[var(--text-color-secondary)] mt-0.5">
|
||
{{ ageLabel }}
|
||
<template v-if="patientData.pronomes"> · {{ patientData.pronomes }}</template>
|
||
</p>
|
||
<p v-if="naturalidade || estado"
|
||
class="text-xs text-[var(--text-color-secondary)]">
|
||
{{ dash(naturalidade) }}<template v-if="estado">, {{ estado }}</template>
|
||
</p>
|
||
<!-- Status + Convênio + Scope -->
|
||
<div class="flex flex-wrap gap-1 mt-2 xl:justify-center">
|
||
<Tag v-if="statusPaciente" :value="statusPaciente" severity="success" class="!text-[0.68rem]" />
|
||
<Tag v-if="convenio" :value="convenio" severity="info" class="!text-[0.68rem]" />
|
||
<Tag v-if="patientScope" :value="patientScope" severity="secondary" class="!text-[0.68rem]" />
|
||
</div>
|
||
<!-- Tags coloridas -->
|
||
<div v-if="tags?.length" class="flex flex-wrap gap-1 mt-1.5 xl:justify-center">
|
||
<span v-for="t in tags" :key="t.id"
|
||
class="inline-flex items-center px-2 py-0.5 rounded-full text-[0.68rem] font-medium"
|
||
:style="tagStyle(t)">
|
||
{{ t.name }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Métricas 2×2 -->
|
||
<div v-if="totalSessoes != null || comparecimentoPct != null || ltvTotal != null || diasUltimaSessao != null"
|
||
class="grid grid-cols-2 gap-3 text-center pb-4 mb-4 border-b border-[var(--surface-border)]">
|
||
<div v-if="totalSessoes != null">
|
||
<p class="text-xl font-bold text-[var(--text-color)]">{{ totalSessoes }}</p>
|
||
<p class="text-[0.65rem] text-[var(--text-color-secondary)] mt-0.5">Sessões</p>
|
||
</div>
|
||
<div v-if="comparecimentoPct != null">
|
||
<p class="text-xl font-bold text-emerald-600">{{ comparecimentoPct }}%</p>
|
||
<p class="text-[0.65rem] text-[var(--text-color-secondary)] mt-0.5">Comparec.</p>
|
||
</div>
|
||
<div v-if="ltvTotal != null">
|
||
<p class="text-base font-bold text-[var(--text-color)]">{{ fmtCurrency(ltvTotal) }}</p>
|
||
<p class="text-[0.65rem] text-[var(--text-color-secondary)] mt-0.5">LTV total</p>
|
||
</div>
|
||
<div v-if="diasUltimaSessao != null">
|
||
<p class="text-xl font-bold text-amber-500">{{ diasUltimaSessao }}d</p>
|
||
<p class="text-[0.65rem] text-[var(--text-color-secondary)] mt-0.5">Últ. sessão</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Grupo e Tags -->
|
||
<div class="flex flex-col gap-3 text-sm text-[var(--text-color-secondary)] pb-4 mb-4 border-b border-[var(--surface-border)]">
|
||
<div>
|
||
<p class="text-[0.62rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] opacity-50 mb-1">
|
||
{{ groupCountLabel }}
|
||
</p>
|
||
<p class="text-xs font-medium text-[var(--text-color)]">{{ groupLabel }}</p>
|
||
</div>
|
||
<div>
|
||
<p class="text-[0.62rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] opacity-50 mb-1.5">
|
||
Tags
|
||
</p>
|
||
<div class="flex flex-wrap gap-1.5">
|
||
<Chip v-for="t in tags" :key="t.id"
|
||
:label="t.name" :style="tagStyle(t)"
|
||
class="!border !rounded-full !text-xs" />
|
||
<span v-if="!tags?.length" class="text-xs text-[var(--text-color-secondary)]">—</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Nav tabs principais -->
|
||
<div class="flex flex-col gap-0.5">
|
||
<p class="text-[0.62rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] opacity-50 px-3 mb-1">
|
||
Navegação
|
||
</p>
|
||
<button
|
||
v-for="(tab, i) in mainTabs" :key="i"
|
||
type="button"
|
||
class="flex items-center gap-2.5 rounded-lg px-3 py-2 text-left
|
||
text-[0.82rem] border transition-colors duration-100"
|
||
:class="activeTab === i
|
||
? 'bg-indigo-50 border-indigo-200 text-indigo-700 font-semibold'
|
||
: 'border-transparent text-[var(--text-color)] hover:bg-[var(--surface-ground)] font-medium'"
|
||
@click="activeTab = i; if(i === 0) activeAccValues = ['0']"
|
||
>
|
||
<i :class="tab.icon" class="text-sm opacity-60 shrink-0" />
|
||
<span>{{ tab.label }}</span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Submenu Perfil (desktop, aba 0) -->
|
||
<div v-if="activeTab === 0 && !isCompact"
|
||
class="mt-3 pt-3 border-t border-[var(--surface-border)] flex flex-col gap-0.5">
|
||
<p class="text-[0.62rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] opacity-50 px-3 mb-1">
|
||
Seções do perfil
|
||
</p>
|
||
<button
|
||
v-for="item in profileNavItems" :key="item.value"
|
||
type="button"
|
||
class="flex items-center gap-2.5 rounded-lg px-3 py-1.5 text-left
|
||
text-[0.79rem] border transition-colors duration-100"
|
||
:class="activeAccValue === item.value
|
||
? 'bg-indigo-50 border-indigo-200 text-indigo-700 font-semibold'
|
||
: 'border-transparent text-[var(--text-color-secondary)] hover:bg-[var(--surface-ground)] font-medium'"
|
||
@click="openPanel(Number(item.value))"
|
||
>
|
||
<i :class="item.icon" class="text-xs opacity-60 shrink-0" />
|
||
<span>{{ item.label }}</span>
|
||
</button>
|
||
</div>
|
||
</aside>
|
||
|
||
<!-- ══ MAIN ═══════════════════════════════════════ -->
|
||
<main class="min-w-0 rounded-xl border border-[var(--surface-border)]
|
||
bg-[var(--surface-card)] shadow-sm overflow-hidden">
|
||
|
||
<!-- Tab bar -->
|
||
<div class="flex border-b border-[var(--surface-border)] overflow-x-auto scrollbar-none">
|
||
<button
|
||
v-for="(tab, i) in mainTabs" :key="i"
|
||
type="button"
|
||
class="flex items-center gap-1.5 px-4 py-3 text-[0.82rem] font-medium
|
||
whitespace-nowrap border-b-2 transition-colors duration-150 shrink-0"
|
||
:class="activeTab === i
|
||
? 'border-[var(--primary-color)] text-[var(--primary-color)]'
|
||
: 'border-transparent text-[var(--text-color-secondary)] hover:text-[var(--text-color)]'"
|
||
@click="activeTab = i; if(i === 0) activeAccValues = ['0']"
|
||
>
|
||
<i :class="tab.icon" class="text-sm" />
|
||
{{ tab.label }}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- ═══════════════════════════════════════════
|
||
ABA 0: PERFIL — accordion
|
||
═══════════════════════════════════════════ -->
|
||
<div v-show="activeTab === 0">
|
||
|
||
<!-- Banner risco (no topo do conteúdo, abaixo das tabs) -->
|
||
<div v-if="riscoElevado"
|
||
class="flex items-start gap-3 border-b border-red-100 bg-red-50 px-4 py-3">
|
||
<i class="pi pi-exclamation-circle text-red-500 mt-0.5 shrink-0" />
|
||
<div>
|
||
<p class="text-sm font-semibold text-red-700">
|
||
Atenção — paciente com risco elevado sinalizado
|
||
</p>
|
||
<p class="text-xs text-red-500 mt-0.5">
|
||
Verifique as anotações internas e a seção de sessões para mais detalhes.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Nav compacto (<xl) -->
|
||
<div v-if="isCompact"
|
||
class="sticky top-0 z-10 border-b border-[var(--surface-border)]
|
||
bg-[var(--surface-card)] px-4 py-2.5">
|
||
<Button type="button" class="w-full !rounded-full"
|
||
icon="pi pi-chevron-down" iconPos="right"
|
||
:label="selectedNav ? selectedNav.label : 'Selecionar seção'"
|
||
@click="toggleNav($event)" />
|
||
<Popover ref="navPopover">
|
||
<div class="flex min-w-[240px] flex-col gap-0.5 p-1">
|
||
<button
|
||
v-for="item in profileNavItems" :key="item.value"
|
||
type="button"
|
||
class="flex items-center gap-2.5 rounded-lg px-3 py-2
|
||
text-left text-sm border cursor-pointer"
|
||
:class="activeAccValue === item.value
|
||
? 'bg-indigo-50 border-indigo-200 text-indigo-700 font-semibold'
|
||
: 'border-transparent text-[var(--text-color)] hover:bg-[var(--surface-ground)] font-medium'"
|
||
@click="selectNav(item)"
|
||
>
|
||
<i :class="item.icon" class="text-sm opacity-60 shrink-0" />
|
||
<span>{{ item.label }}</span>
|
||
</button>
|
||
</div>
|
||
</Popover>
|
||
</div>
|
||
|
||
<!-- Accordion -->
|
||
<div class="p-4">
|
||
<Accordion multiple v-model:value="activeAccValues" class="accordion-clean">
|
||
|
||
<!-- ── 1. INFORMAÇÕES PESSOAIS ───────────── -->
|
||
<AccordionPanel value="0">
|
||
<AccordionHeader :ref="(el) => setPanelHeaderRef(el, 0)">
|
||
<span class="flex items-center gap-2 text-[0.83rem] font-bold">
|
||
<i class="pi pi-pencil text-indigo-500 text-sm" />
|
||
1. Informações Pessoais
|
||
</span>
|
||
</AccordionHeader>
|
||
<AccordionContent>
|
||
<div class="pt-3 grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
|
||
<!-- Dados de cadastro -->
|
||
<div class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
|
||
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] mb-3 opacity-70">Dados de cadastro</p>
|
||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Nome completo</span>
|
||
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ dash(nomeCompleto) }}</span>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Data de nascimento</span>
|
||
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">
|
||
{{ fmtDateBR(birthValue) }}
|
||
<span v-if="calcAge(birthValue) != null" class="text-[var(--text-color-secondary)] font-normal">({{ calcAge(birthValue) }} a)</span>
|
||
</span>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Gênero</span>
|
||
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ fmtGender(genero) }}</span>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Estado civil</span>
|
||
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ fmtMarital(estadoCivil) }}</span>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">CPF</span>
|
||
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right font-mono">{{ fmtCPF(patientData.cpf) }}</span>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">RG</span>
|
||
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right font-mono">{{ fmtRG(patientData.rg) }}</span>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-4 py-1.5">
|
||
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Naturalidade</span>
|
||
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ dash(naturalidade) }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Contato + Origem -->
|
||
<div class="space-y-3">
|
||
<div class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
|
||
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] mb-3 opacity-70">Contato</p>
|
||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Telefone / Celular</span>
|
||
<a v-if="telefone" :href="`tel:${telefone}`" class="text-[0.8rem] font-medium text-right text-[var(--primary-color)] hover:underline">{{ fmtPhoneMobile(telefone) }}</a>
|
||
<span v-else class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">—</span>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Telefone alternativo</span>
|
||
<a v-if="telefoneAlternativo" :href="`tel:${telefoneAlternativo}`" class="text-[0.8rem] font-medium text-right text-[var(--primary-color)] hover:underline">{{ fmtPhoneMobile(telefoneAlternativo) }}</a>
|
||
<span v-else class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">—</span>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Email principal</span>
|
||
<a v-if="emailPrincipal" :href="`mailto:${emailPrincipal}`" class="text-[0.8rem] font-medium text-right text-[var(--primary-color)] hover:underline truncate max-w-[200px] inline-block">{{ emailPrincipal }}</a>
|
||
<span v-else class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">—</span>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-4 py-1.5">
|
||
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Email alternativo</span>
|
||
<a v-if="emailAlternativo" :href="`mailto:${emailAlternativo}`" class="text-[0.8rem] font-medium text-right text-[var(--primary-color)] hover:underline truncate max-w-[200px] inline-block">{{ emailAlternativo }}</a>
|
||
<span v-else class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">—</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
|
||
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] mb-3 opacity-70">Origem</p>
|
||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">{{ groupCountLabel }}</span>
|
||
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ groupLabel }}</span>
|
||
</div>
|
||
<div class="flex items-start justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Tags</span>
|
||
<div class="flex flex-wrap gap-1 justify-end">
|
||
<span v-for="t in tags" :key="t.id"
|
||
class="inline-flex items-center px-2 py-0.5 rounded-full text-[0.72rem] font-medium"
|
||
:style="tagStyle(t)">{{ t.name }}</span>
|
||
<span v-if="!tags?.length" class="text-[0.8rem] text-[var(--text-color)] font-medium">—</span>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Onde nos conheceu?</span>
|
||
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ dash(ondeNosConheceu) }}</span>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-4 py-1.5">
|
||
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Encaminhado por</span>
|
||
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ dash(encaminhadoPor) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Observações full-width -->
|
||
<div v-if="observacoes"
|
||
class="md:col-span-2 rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
|
||
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] mb-3 opacity-70">Observações</p>
|
||
<p class="text-[0.83rem] text-[var(--text-color)] leading-relaxed whitespace-pre-wrap">{{ observacoes }}</p>
|
||
</div>
|
||
</div>
|
||
</AccordionContent>
|
||
</AccordionPanel>
|
||
|
||
<!-- ── 2. ENDEREÇO ───────────────────────── -->
|
||
<AccordionPanel value="1">
|
||
<AccordionHeader :ref="(el) => setPanelHeaderRef(el, 1)">
|
||
<span class="flex items-center gap-2 text-[0.83rem] font-bold">
|
||
<i class="pi pi-map-marker text-indigo-500 text-sm" />
|
||
2. Endereço
|
||
</span>
|
||
</AccordionHeader>
|
||
<AccordionContent>
|
||
<div class="pt-3">
|
||
<div class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
|
||
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] mb-3 opacity-70">Localização</p>
|
||
<div class="grid grid-cols-1 sm:grid-cols-2">
|
||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">CEP</span>
|
||
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right font-mono">{{ dash(cep) }}</span>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">País</span>
|
||
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ dash(pais) }}</span>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Cidade</span>
|
||
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ dash(cidade) }}</span>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Estado</span>
|
||
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ dash(estado) }}</span>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Endereço</span>
|
||
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ dash(endereco) }}</span>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Número</span>
|
||
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ dash(numero) }}</span>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Bairro</span>
|
||
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ dash(bairro) }}</span>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-4 py-1.5">
|
||
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Complemento</span>
|
||
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ dash(complemento) }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</AccordionContent>
|
||
</AccordionPanel>
|
||
|
||
<!-- ── 3. DADOS ADICIONAIS ───────────────── -->
|
||
<AccordionPanel value="2">
|
||
<AccordionHeader :ref="(el) => setPanelHeaderRef(el, 2)">
|
||
<span class="flex items-center gap-2 text-[0.83rem] font-bold">
|
||
<i class="pi pi-tags text-indigo-500 text-sm" />
|
||
3. Dados Adicionais
|
||
</span>
|
||
</AccordionHeader>
|
||
<AccordionContent>
|
||
<div class="pt-3">
|
||
<div class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
|
||
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] mb-3 opacity-70">Formação & família</p>
|
||
<div class="grid grid-cols-1 sm:grid-cols-2">
|
||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Escolaridade</span>
|
||
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ dash(escolaridade) }}</span>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Profissão</span>
|
||
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ dash(profissao) }}</span>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Nome de um parente</span>
|
||
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ dash(nomeParente) }}</span>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Grau de parentesco</span>
|
||
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ dash(grauParentesco) }}</span>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-4 py-1.5">
|
||
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Telefone do parente</span>
|
||
<a v-if="telefoneParente" :href="`tel:${telefoneParente}`" class="text-[0.8rem] font-medium text-right text-[var(--primary-color)] hover:underline">{{ fmtPhoneMobile(telefoneParente) }}</a>
|
||
<span v-else class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">—</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</AccordionContent>
|
||
</AccordionPanel>
|
||
|
||
<!-- ── 4. RESPONSÁVEL ────────────────────── -->
|
||
<AccordionPanel value="3">
|
||
<AccordionHeader :ref="(el) => setPanelHeaderRef(el, 3)">
|
||
<span class="flex items-center gap-2 text-[0.83rem] font-bold">
|
||
<i class="pi pi-users text-indigo-500 text-sm" />
|
||
4. Responsável
|
||
</span>
|
||
</AccordionHeader>
|
||
<AccordionContent>
|
||
<div class="pt-3">
|
||
<div class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
|
||
<p class="text-[0.65rem] font-bold uppercase tracking-widest text-[var(--text-color-secondary)] mb-3 opacity-70">Dados do responsável</p>
|
||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Nome</span>
|
||
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">{{ dash(nomeResponsavel) }}</span>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">CPF</span>
|
||
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right font-mono">{{ fmtCPF(cpfResponsavel) }}</span>
|
||
</div>
|
||
<div class="flex items-baseline justify-between gap-4 py-1.5 border-b border-[var(--surface-border)]">
|
||
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Telefone</span>
|
||
<a v-if="telefoneResponsavel" :href="`tel:${telefoneResponsavel}`" class="text-[0.8rem] font-medium text-right text-[var(--primary-color)] hover:underline">{{ fmtPhoneMobile(telefoneResponsavel) }}</a>
|
||
<span v-else class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">—</span>
|
||
</div>
|
||
<div v-if="observacaoResponsavel" class="pt-2">
|
||
<p class="text-[0.75rem] text-[var(--text-color-secondary)] mb-1">Observação</p>
|
||
<p class="text-[0.83rem] text-[var(--text-color)] leading-relaxed whitespace-pre-wrap">{{ observacaoResponsavel }}</p>
|
||
</div>
|
||
<div v-else class="flex items-baseline justify-between gap-4 py-1.5">
|
||
<span class="text-[0.8rem] text-[var(--text-color-secondary)] shrink-0">Observação</span>
|
||
<span class="text-[0.8rem] text-[var(--text-color)] font-medium text-right">—</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</AccordionContent>
|
||
</AccordionPanel>
|
||
|
||
<!-- ── 5. ANOTAÇÕES INTERNAS ─────────────── -->
|
||
<AccordionPanel value="4">
|
||
<AccordionHeader :ref="(el) => setPanelHeaderRef(el, 4)">
|
||
<span class="flex items-center gap-2 text-[0.83rem] font-bold">
|
||
<i class="pi pi-file-edit text-indigo-500 text-sm" />
|
||
5. Anotações Internas
|
||
</span>
|
||
</AccordionHeader>
|
||
<AccordionContent>
|
||
<div class="pt-3">
|
||
<div class="rounded-xl border border-[var(--surface-border)] bg-[var(--surface-ground)] p-4">
|
||
<div class="flex items-center gap-1.5 text-xs text-[var(--text-color-secondary)] opacity-70 mb-3">
|
||
<i class="pi pi-lock text-[0.7rem]" />
|
||
Campo interno: não aparece no cadastro externo.
|
||
</div>
|
||
<p class="text-[0.83rem] text-[var(--text-color)] leading-relaxed whitespace-pre-wrap min-h-[4rem]">
|
||
{{ dash(notasInternas) }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</AccordionContent>
|
||
</AccordionPanel>
|
||
|
||
<!-- ── 6. SESSÕES ────────────────────────── -->
|
||
<AccordionPanel value="5">
|
||
<AccordionHeader :ref="(el) => setPanelHeaderRef(el, 5)">
|
||
<span class="flex items-center gap-2 text-[0.83rem] font-bold">
|
||
<i class="pi pi-calendar text-indigo-500 text-sm" />
|
||
6. Sessões
|
||
</span>
|
||
</AccordionHeader>
|
||
<AccordionContent>
|
||
<div class="pt-3">
|
||
<div v-if="sessionsLoading"
|
||
class="flex items-center gap-2 text-sm text-[var(--text-color-secondary)] py-4">
|
||
<i class="pi pi-spin pi-spinner" /> Carregando sessões…
|
||
</div>
|
||
<div v-else-if="!sessions.length"
|
||
class="text-sm text-[var(--text-color-secondary)] py-4 text-center">
|
||
Nenhuma sessão registrada para este paciente.
|
||
</div>
|
||
<div v-else class="flex flex-col gap-2">
|
||
<div
|
||
v-for="s in sessions" :key="s.id"
|
||
class="rounded-xl border border-[var(--surface-border)]
|
||
bg-[var(--surface-ground)] px-4 py-3
|
||
flex flex-col sm:flex-row sm:items-center
|
||
sm:justify-between gap-2"
|
||
>
|
||
<div class="flex flex-col gap-1">
|
||
<p class="text-[0.85rem] font-semibold text-[var(--text-color)]">
|
||
{{ s.titulo_custom || s.titulo || (s.tipo ? s.tipo : 'Sessão') }}
|
||
</p>
|
||
<div class="text-[0.76rem] text-[var(--text-color-secondary)] flex flex-wrap gap-x-3 gap-y-1">
|
||
<span><i class="pi pi-calendar mr-1 opacity-50" />{{ fmtDateTimeBR(s.inicio_em) }}</span>
|
||
<span v-if="sessionDuration(s.inicio_em, s.fim_em)">
|
||
<i class="pi pi-clock mr-1 opacity-50" />{{ sessionDuration(s.inicio_em, s.fim_em) }}
|
||
</span>
|
||
<span v-if="s.modalidade">
|
||
<i class="pi pi-video mr-1 opacity-50" />{{ s.modalidade === 'online' ? 'Online' : 'Presencial' }}
|
||
</span>
|
||
</div>
|
||
<p v-if="s.observacoes"
|
||
class="text-[0.76rem] text-[var(--text-color-secondary)] mt-0.5 line-clamp-2">
|
||
{{ s.observacoes }}
|
||
</p>
|
||
</div>
|
||
<Tag :value="STATUS_LABEL[s.status] || s.status || 'Agendado'"
|
||
:severity="STATUS_SEVERITY[s.status] || 'info'"
|
||
class="shrink-0" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</AccordionContent>
|
||
</AccordionPanel>
|
||
|
||
</Accordion>
|
||
</div>
|
||
</div><!-- /aba perfil -->
|
||
|
||
<!-- ABAS PLACEHOLDER -->
|
||
<div v-show="activeTab === 1" class="p-10 text-center text-[var(--text-color-secondary)]">
|
||
<i class="pi pi-file-edit text-4xl mb-3 block opacity-30" /><p class="text-sm">Em breve</p>
|
||
</div>
|
||
<div v-show="activeTab === 2" class="p-10 text-center text-[var(--text-color-secondary)]">
|
||
<i class="pi pi-calendar text-4xl mb-3 block opacity-30" /><p class="text-sm">Em breve</p>
|
||
</div>
|
||
<div v-show="activeTab === 3" class="p-10 text-center text-[var(--text-color-secondary)]">
|
||
<i class="pi pi-wallet text-4xl mb-3 block opacity-30" /><p class="text-sm">Em breve</p>
|
||
</div>
|
||
<div v-show="activeTab === 4" class="py-2">
|
||
<DocumentsListPage
|
||
:patientId="patient?.id"
|
||
:patientName="patient?.nome_completo || patient?.nome_social || ''"
|
||
embedded
|
||
/>
|
||
</div>
|
||
<div v-show="activeTab === 5" class="p-2">
|
||
<PatientConversationsTab
|
||
:patient-id="patient?.id"
|
||
:patient-name="patient?.nome_completo || patient?.nome_social || ''"
|
||
/>
|
||
</div>
|
||
|
||
</main>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── FOOTER ──────────────────────────────────────────── -->
|
||
<template #footer>
|
||
<div class="flex items-center justify-between gap-2 flex-wrap px-1">
|
||
<Button
|
||
v-if="activeTab === 0"
|
||
:label="allOpen ? 'Fechar seções' : 'Abrir seções'"
|
||
:icon="allOpen ? 'pi pi-angle-double-up' : 'pi pi-angle-double-down'"
|
||
severity="secondary" outlined class="rounded-full"
|
||
@click="toggleAllAccordions"
|
||
/>
|
||
<div v-else />
|
||
<div class="flex items-center gap-2">
|
||
<Button label="Copiar resumo" icon="pi pi-copy"
|
||
severity="secondary" outlined class="rounded-full"
|
||
@click="copyResumo" />
|
||
<Button label="Editar paciente" icon="pi pi-pencil"
|
||
severity="secondary" outlined class="rounded-full"
|
||
@click="editPatient" />
|
||
<Button label="Fechar" icon="pi pi-times"
|
||
severity="secondary" class="rounded-full"
|
||
@click="close" />
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
</Dialog>
|
||
</template>
|
||
|
||
<style scoped>
|
||
:deep(.accordion-clean .p-accordionpanel) {
|
||
border-radius: 0.75rem;
|
||
margin-bottom: 0.5rem;
|
||
border: 1px solid var(--surface-border);
|
||
overflow: hidden;
|
||
}
|
||
:deep(.accordion-clean .p-accordionpanel:last-child) { margin-bottom: 0; }
|
||
:deep(.accordion-clean .p-accordionheader) {
|
||
background: var(--surface-ground);
|
||
border: none;
|
||
padding: 0.875rem 1rem;
|
||
}
|
||
:deep(.accordion-clean .p-accordionheader:hover) {
|
||
background: var(--surface-hover, #f1f5f9);
|
||
}
|
||
:deep(.accordion-clean .p-accordioncontent-content) {
|
||
padding: 0 1rem 1rem;
|
||
border-top: 1px solid var(--surface-border);
|
||
}
|
||
.scrollbar-none::-webkit-scrollbar { display: none; }
|
||
.scrollbar-none { -ms-overflow-style: none; scrollbar-width: none; }
|
||
</style>
|