2dae4a11ae
ROADMAP item #1.3 #11. localStorage por user_id pra isolar sessoes diferentes no mesmo browser. ROADMAP sugeria localStorage OU tabela user_recent_access — escolhi localStorage por simplicidade (sem migration adicional + zero round-trip por visita). composables/useRecentPatients.js: - useRecentPatients() — composable reativo Tipo A: items + hasItems + addVisit + remove + clear + refresh - registerPatientVisit(patient) — helper stateless pra usar fora de setup (ex: navigation guards, action handlers) - Sincroniza entre instancias na mesma aba via CustomEvent + 'storage' - Max 5 items. Dedup por id, novo no topo. Wire-up de visita (registra ao carregar prontuario): - MelissaPaciente.vue: registerPatientVisit no loadAll apos detail.load - PatientProntuario.vue: registerPatientVisit em loadDetail apos p resolved Wire-up de visualizacao (mostra quando query vazia): - GlobalSearch.vue: grupo "Acessados recentemente" antes dos Atalhos. goTo("recent") navega pra /therapist/patients/:id. - MelissaBusca.vue: grupo "Acessados recentemente". emit('paciente') reusando a logica do MelissaLayout que ja navega pra /melissa/paciente?id=X. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
3597 lines
181 KiB
Vue
3597 lines
181 KiB
Vue
<!--
|
||
|--------------------------------------------------------------------------
|
||
| 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 { useConfirm } from 'primevue/useconfirm';
|
||
import { supabase } from '@/lib/supabase/client';
|
||
import { useTenantStore } from '@/stores/tenantStore';
|
||
import { useConversationDrawerStore } from '@/stores/conversationDrawerStore';
|
||
|
||
import DocumentsListPage from '@/features/documents/DocumentsListPage.vue';
|
||
import PatientConversationsTab from './PatientConversationsTab.vue';
|
||
import { registerPatientVisit } from '@/composables/useRecentPatients';
|
||
|
||
// ── PROPS / EMITS ──────────────────────────────────────────────
|
||
const props = defineProps({
|
||
modelValue: { type: Boolean, default: false },
|
||
patient: { type: Object, default: () => ({}) },
|
||
});
|
||
const emit = defineEmits(['update:modelValue', 'close', 'edit', 'add-financial']);
|
||
|
||
const toast = useToast();
|
||
const confirm = useConfirm();
|
||
const tenantStore = useTenantStore();
|
||
const conversationDrawerStore = useConversationDrawerStore();
|
||
|
||
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 ────────────────────────────────────────────
|
||
// activeTab=0 abre o dashboard "Visão Geral" (resumo + timeline + KPIs).
|
||
// As abas seguintes mantém o que já existia, deslocadas em +1.
|
||
const activeTab = ref(0);
|
||
const mainTabs = [
|
||
{ label: 'Visão Geral', icon: 'pi pi-chart-line' },
|
||
{ 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) {
|
||
// Aba 1 = "Perfil" (após inserção da aba 0 "Visão Geral").
|
||
activeTab.value = 1;
|
||
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);
|
||
|
||
// Dashboard "Visão Geral" — agregações leves carregadas em paralelo.
|
||
const recentMessages = ref([]); // últimas 4 trocadas (in/out)
|
||
const messagesLoading = ref(false);
|
||
const financialRecords = ref([]); // últimos 12 lançamentos
|
||
const financialLoading = ref(false);
|
||
|
||
// Aba Documentos — só metadata pra KPIs (DocumentsListPage carrega o
|
||
// detalhe via composable próprio). Mantemos esse loader paralelo pra
|
||
// que os números apareçam imediatamente sem depender do componente embed.
|
||
const documentsList = ref([]);
|
||
const documentsLoading = 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);
|
||
|
||
// ── DASHBOARD DERIVADOS (Visão Geral) ───────────────────────────
|
||
// sessions vem ordenado DESC (mais recente primeiro). Derivamos:
|
||
// - presentes: status realizado/presente
|
||
// - faltas/cancel.: contagem pra contraste no card
|
||
// - próxima: primeira futura na ordem cronológica
|
||
// - últimas atendidas: top 6 realizadas pra timeline
|
||
const sessoesPresentes = computed(() =>
|
||
sessions.value.filter((s) => /realiz|present/i.test(String(s.status || ''))).length
|
||
);
|
||
const sessoesFaltadas = computed(() =>
|
||
sessions.value.filter((s) => /falta/i.test(String(s.status || ''))).length
|
||
);
|
||
const sessoesCanceladas = computed(() =>
|
||
sessions.value.filter((s) => /cancel|remarca/i.test(String(s.status || ''))).length
|
||
);
|
||
const proximaSessao = computed(() => {
|
||
const now = Date.now();
|
||
// ordem DESC; pegar a primeira cuja inicio_em > now (no array, é a última futura)
|
||
const futuras = sessions.value
|
||
.filter((s) => s.inicio_em && new Date(s.inicio_em).getTime() > now)
|
||
.sort((a, b) => new Date(a.inicio_em) - new Date(b.inicio_em));
|
||
return futuras[0] || null;
|
||
});
|
||
const ultimasAtendidas = computed(() =>
|
||
sessions.value
|
||
.filter((s) => /realiz|present|falt|cancel|remarca/i.test(String(s.status || '')))
|
||
.slice(0, 6)
|
||
);
|
||
function diasAteSessao(iso) {
|
||
if (!iso) return null;
|
||
const ms = new Date(iso).getTime() - Date.now();
|
||
if (Number.isNaN(ms)) return null;
|
||
return Math.ceil(ms / (1000 * 60 * 60 * 24));
|
||
}
|
||
|
||
// Status financeiro: agrega lançamentos pra dizer
|
||
// - emDia: nenhum pendente está vencido (paid_at NULL && due_date < hoje)
|
||
// - proxVenc: próximo pendente com due_date no futuro
|
||
// - totalPendente / totalPago: somatório
|
||
const statusFinanceiro = computed(() => {
|
||
const recs = financialRecords.value;
|
||
if (!recs?.length) {
|
||
return { emDia: null, proxVenc: null, totalPendente: 0, totalPago: 0, vencidos: 0 };
|
||
}
|
||
const now = Date.now();
|
||
const pendentes = recs.filter((r) => !r.paid_at);
|
||
const pagos = recs.filter((r) => !!r.paid_at);
|
||
const vencidos = pendentes.filter((r) => r.due_date && new Date(r.due_date + 'T23:59:59').getTime() < now);
|
||
const proxVenc = pendentes
|
||
.filter((r) => r.due_date && new Date(r.due_date + 'T00:00:00').getTime() >= now)
|
||
.sort((a, b) => new Date(a.due_date) - new Date(b.due_date))[0] || null;
|
||
const totalPendente = pendentes.reduce((acc, r) => acc + (Number(r.amount) || 0), 0);
|
||
const totalPago = pagos.reduce((acc, r) => acc + (Number(r.amount) || 0), 0);
|
||
return {
|
||
emDia: vencidos.length === 0,
|
||
proxVenc,
|
||
totalPendente,
|
||
totalPago,
|
||
vencidos: vencidos.length
|
||
};
|
||
});
|
||
|
||
// Última atividade na conversa (data + lado: in/out)
|
||
const ultimaMensagem = computed(() => recentMessages.value[0] || null);
|
||
|
||
// ── DASHBOARD: aba Financeiro ─────────────────────────────────
|
||
// Status de cada lançamento; "vencido" considera due_date < hoje && !paid_at
|
||
function recordStatus(r) {
|
||
if (r?.paid_at) return 'pago';
|
||
if (r?.due_date) {
|
||
const ms = new Date(r.due_date + 'T23:59:59').getTime();
|
||
if (!Number.isNaN(ms) && ms < Date.now()) return 'vencido';
|
||
}
|
||
return 'pendente';
|
||
}
|
||
const RECORD_STATUS_LABEL = { pago: 'Pago', pendente: 'Pendente', vencido: 'Vencido' };
|
||
const recordsOrdenados = computed(() => {
|
||
return [...financialRecords.value].sort((a, b) => {
|
||
// due_date primeiro (DESC), depois created_at
|
||
const da = a.due_date || a.created_at;
|
||
const db = b.due_date || b.created_at;
|
||
return new Date(db) - new Date(da);
|
||
});
|
||
});
|
||
function fmtPaymentMethod(v) {
|
||
const s = String(v || '').toLowerCase();
|
||
if (!s) return '';
|
||
if (s === 'pix') return 'PIX';
|
||
if (s === 'cartao' || s === 'cartão' || s === 'credit_card') return 'Cartão';
|
||
if (s === 'dinheiro' || s === 'cash') return 'Dinheiro';
|
||
if (s === 'boleto') return 'Boleto';
|
||
if (s === 'transferencia' || s === 'transfer') return 'Transferência';
|
||
return v;
|
||
}
|
||
|
||
// ── DASHBOARD: aba Agenda ─────────────────────────────────────
|
||
// Filtro + agrupamento cronológico das sessões (DESC).
|
||
const agendaFilter = ref('all'); // all | future | past | realiz | falt | cancel
|
||
const agendaFilters = [
|
||
{ value: 'all', label: 'Todas', icon: 'pi pi-list' },
|
||
{ value: 'future', label: 'Próximas', icon: 'pi pi-calendar-plus' },
|
||
{ value: 'past', label: 'Passadas', icon: 'pi pi-history' },
|
||
{ value: 'realiz', label: 'Realizadas', icon: 'pi pi-check-circle' },
|
||
{ value: 'falt', label: 'Faltas', icon: 'pi pi-user-minus' },
|
||
{ value: 'cancel', label: 'Canceladas', icon: 'pi pi-ban' }
|
||
];
|
||
const agendaSessoesFiltradas = computed(() => {
|
||
const list = sessions.value || [];
|
||
const now = Date.now();
|
||
switch (agendaFilter.value) {
|
||
case 'future': return list.filter((s) => s.inicio_em && new Date(s.inicio_em).getTime() > now);
|
||
case 'past': return list.filter((s) => s.inicio_em && new Date(s.inicio_em).getTime() <= now);
|
||
case 'realiz': return list.filter((s) => /realiz|present/i.test(String(s.status || '')));
|
||
case 'falt': return list.filter((s) => /falt/i.test(String(s.status || '')));
|
||
case 'cancel': return list.filter((s) => /cancel|remarca/i.test(String(s.status || '')));
|
||
default: return list;
|
||
}
|
||
});
|
||
// Agrupa por "MÊS de YYYY" mantendo ordem DESC (mais recente primeiro)
|
||
const agendaAgrupadas = computed(() => {
|
||
const groups = [];
|
||
let key = null;
|
||
for (const ev of agendaSessoesFiltradas.value) {
|
||
if (!ev.inicio_em) continue;
|
||
const d = new Date(ev.inicio_em);
|
||
const k = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||
if (k !== key) {
|
||
key = k;
|
||
groups.push({
|
||
key: k,
|
||
label: d.toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' })
|
||
.replace(/(^|\s)\S/g, (l) => l.toUpperCase()),
|
||
items: []
|
||
});
|
||
}
|
||
groups[groups.length - 1].items.push(ev);
|
||
}
|
||
return groups;
|
||
});
|
||
|
||
// Atualiza status de uma sessão específica e recarrega lista.
|
||
async function updateSessionStatus(ev, novoStatus, msg) {
|
||
if (!ev?.id || sessionBusy.value) return;
|
||
sessionBusy.value = true;
|
||
try {
|
||
const { error } = await supabase
|
||
.from('agenda_eventos')
|
||
.update({ status: novoStatus })
|
||
.eq('id', ev.id);
|
||
if (error) throw error;
|
||
toast.add({ severity: 'success', summary: msg, life: 2200 });
|
||
const pid = patientData.value?.id;
|
||
if (pid) await loadSessions(pid);
|
||
} catch (e) {
|
||
toast.add({ severity: 'error', summary: 'Falha ao atualizar', detail: e?.message || 'Erro inesperado', life: 4000 });
|
||
} finally {
|
||
sessionBusy.value = false;
|
||
}
|
||
}
|
||
|
||
// ── DASHBOARD: aba Conversas ─────────────────────────────────
|
||
// recentMessages traz até 200 mensagens — agregamos in/out, primeiro
|
||
// e último contato. Canais únicos pra mostrar quais foram usados.
|
||
const convTotal = computed(() => recentMessages.value.length);
|
||
const convInbound = computed(() => recentMessages.value.filter((m) => m.direction === 'inbound').length);
|
||
const convOutbound = computed(() => recentMessages.value.filter((m) => m.direction === 'outbound').length);
|
||
const convLast = computed(() => recentMessages.value[0] || null);
|
||
const convFirst = computed(() => recentMessages.value[recentMessages.value.length - 1] || null);
|
||
const convChannels = computed(() => {
|
||
const set = new Set();
|
||
for (const m of recentMessages.value) if (m.channel) set.add(m.channel);
|
||
return [...set];
|
||
});
|
||
function chConvLabel(c) {
|
||
const k = String(c || '').toLowerCase();
|
||
if (k === 'whatsapp') return 'WhatsApp';
|
||
if (k === 'sms') return 'SMS';
|
||
if (k === 'email') return 'E-mail';
|
||
return c || '';
|
||
}
|
||
|
||
// Adicionar telefone WhatsApp ao paciente quando não tem cadastrado.
|
||
// Sem isso, conversationDrawerStore.openForPatient falha silencioso e o
|
||
// histórico de mensagens fica vazio. Form simples só com dígitos (10 ou
|
||
// 11 — DDD + número), sem máscara pra não depender de InputMask aqui.
|
||
const phoneDlg = ref(false);
|
||
const phoneInput = ref('');
|
||
const phoneSaving = ref(false);
|
||
function openAddPhone() {
|
||
phoneInput.value = String(patientData.value?.telefone || '').replace(/\D/g, '');
|
||
phoneDlg.value = true;
|
||
}
|
||
// Soft-check: existe outro paciente com esse mesmo número? Considera
|
||
// tanto patients.telefone (legado) quanto contact_phones (novo).
|
||
// Retorna o paciente conflitante ou null. Errors fail-open — não
|
||
// bloqueamos save por falha no check.
|
||
async function findPhoneOwner(digits, excludeId) {
|
||
try {
|
||
const { data: byPat } = await supabase
|
||
.from('patients')
|
||
.select('id, nome_completo')
|
||
.eq('telefone', digits)
|
||
.neq('id', excludeId)
|
||
.limit(1)
|
||
.maybeSingle();
|
||
if (byPat?.id) return byPat;
|
||
|
||
const { data: byCp } = await supabase
|
||
.from('contact_phones')
|
||
.select('entity_id')
|
||
.eq('entity_type', 'patient')
|
||
.eq('number', digits)
|
||
.neq('entity_id', excludeId)
|
||
.limit(1)
|
||
.maybeSingle();
|
||
if (byCp?.entity_id) {
|
||
const { data: p } = await supabase
|
||
.from('patients')
|
||
.select('id, nome_completo')
|
||
.eq('id', byCp.entity_id)
|
||
.maybeSingle();
|
||
return p || null;
|
||
}
|
||
} catch { /* fail open */ }
|
||
return null;
|
||
}
|
||
|
||
async function savePhone() {
|
||
const id = patientData.value?.id;
|
||
const digits = String(phoneInput.value || '').replace(/\D/g, '');
|
||
if (!id) return;
|
||
if (digits.length < 10 || digits.length > 11) {
|
||
toast.add({ severity: 'warn', summary: 'Telefone inválido', detail: 'Use DDD + número (10 ou 11 dígitos).', life: 3500 });
|
||
return;
|
||
}
|
||
const tenantId = tenantStore.activeTenantId;
|
||
if (!tenantId) {
|
||
toast.add({ severity: 'error', summary: 'Sem tenant ativo', life: 3500 });
|
||
return;
|
||
}
|
||
// Soft-check de duplicidade — números compartilhados são legítimos
|
||
// (mãe agendando pra filho, dependentes), mas avisar evita typo
|
||
// virar bug de roteamento de mensagens recebidas via webhook.
|
||
phoneSaving.value = true;
|
||
const owner = await findPhoneOwner(digits, id);
|
||
phoneSaving.value = false;
|
||
if (owner) {
|
||
confirm.require({
|
||
header: 'Telefone já cadastrado',
|
||
message: `O telefone ${fmtPhoneMobile(digits)} já está cadastrado em "${owner.nome_completo}". Salvar mesmo assim?`,
|
||
icon: 'pi pi-exclamation-triangle',
|
||
acceptLabel: 'Salvar mesmo assim',
|
||
rejectLabel: 'Cancelar',
|
||
acceptClass: 'p-button-warning',
|
||
accept: () => _persistPhone(id, digits, tenantId),
|
||
reject: () => {}
|
||
});
|
||
return;
|
||
}
|
||
await _persistPhone(id, digits, tenantId);
|
||
}
|
||
|
||
async function _persistPhone(id, digits, tenantId) {
|
||
phoneSaving.value = true;
|
||
try {
|
||
// Caminho canônico: gravar em contact_phones. O trigger
|
||
// sync_legacy_phone_fields cuida de atualizar patients.telefone
|
||
// automaticamente — fazer UPDATE manual aqui criaria estado
|
||
// inconsistente (campo legado preenchido sem contact_phone
|
||
// correspondente, foi exatamente o bug que afetou pacientes
|
||
// salvos antes desse refator).
|
||
|
||
// 1) Garante o contact_type "whatsapp" (system, slug fixo via
|
||
// seed_014_global_data).
|
||
const { data: ctype, error: errType } = await supabase
|
||
.from('contact_types')
|
||
.select('id')
|
||
.eq('slug', 'whatsapp')
|
||
.order('is_system', { ascending: false })
|
||
.limit(1)
|
||
.maybeSingle();
|
||
if (errType) throw errType;
|
||
if (!ctype?.id) throw new Error('Tipo de contato "WhatsApp" não encontrado.');
|
||
|
||
// 2) Insere ou atualiza em contact_phones (entity_type=patient).
|
||
const { data: existing } = await supabase
|
||
.from('contact_phones')
|
||
.select('id, is_primary')
|
||
.eq('entity_type', 'patient')
|
||
.eq('entity_id', id)
|
||
.eq('contact_type_id', ctype.id)
|
||
.limit(1)
|
||
.maybeSingle();
|
||
|
||
if (existing?.id) {
|
||
const { error: errUpd } = await supabase
|
||
.from('contact_phones')
|
||
.update({ number: digits, whatsapp_linked_at: new Date().toISOString() })
|
||
.eq('id', existing.id);
|
||
if (errUpd) throw errUpd;
|
||
} else {
|
||
const { count } = await supabase
|
||
.from('contact_phones')
|
||
.select('id', { count: 'exact', head: true })
|
||
.eq('entity_type', 'patient')
|
||
.eq('entity_id', id);
|
||
const isPrimary = (count || 0) === 0;
|
||
const { error: errIns } = await supabase
|
||
.from('contact_phones')
|
||
.insert({
|
||
tenant_id: tenantId,
|
||
entity_type: 'patient',
|
||
entity_id: id,
|
||
contact_type_id: ctype.id,
|
||
number: digits,
|
||
is_primary: isPrimary,
|
||
whatsapp_linked_at: new Date().toISOString(),
|
||
position: 10
|
||
});
|
||
if (errIns) throw errIns;
|
||
}
|
||
|
||
// 3) Sincroniza estado local pra UI atualizar (a trigger já
|
||
// gravou no DB, mas patientFull aqui é uma cópia que precisa
|
||
// refletir a mudança).
|
||
patientFull.value = { ...(patientFull.value || {}), telefone: digits };
|
||
toast.add({ severity: 'success', summary: 'Telefone salvo', detail: 'Cadastrado também como WhatsApp.', life: 2400 });
|
||
phoneDlg.value = false;
|
||
|
||
// 5) Retoma o fluxo do drawer se foi acionado por uma tentativa
|
||
// de abertura sem telefone.
|
||
if (_resumeDrawerAfterPhone.value) {
|
||
_resumeDrawerAfterPhone.value = false;
|
||
await conversationDrawerStore.openForPatient(String(id));
|
||
}
|
||
} catch (e) {
|
||
toast.add({ severity: 'error', summary: 'Falha ao salvar', detail: e?.message || 'Erro inesperado', life: 3500 });
|
||
} finally {
|
||
phoneSaving.value = false;
|
||
}
|
||
}
|
||
|
||
// ── DASHBOARD: aba Documentos ─────────────────────────────────
|
||
// Labels amigáveis pra os tipos do schema (espelha TIPOS_DOCUMENTO de
|
||
// useDocuments.js — manter sincronizado se forem incluídos novos).
|
||
const DOC_TYPE_LABEL = {
|
||
laudo: 'Laudo',
|
||
receita: 'Receita',
|
||
exame: 'Exame',
|
||
termo_assinado: 'Termo assinado',
|
||
relatorio: 'Relatório',
|
||
declaracao: 'Declaração',
|
||
outro: 'Outro'
|
||
};
|
||
const docTotal = computed(() => documentsList.value.length);
|
||
const docTopType = computed(() => {
|
||
const por = {};
|
||
for (const d of documentsList.value) {
|
||
const t = d.tipo_documento || 'outro';
|
||
por[t] = (por[t] || 0) + 1;
|
||
}
|
||
const entries = Object.entries(por).sort((a, b) => b[1] - a[1]);
|
||
if (!entries.length) return null;
|
||
const [tipo, count] = entries[0];
|
||
return { tipo, count, label: DOC_TYPE_LABEL[tipo] || tipo };
|
||
});
|
||
const docLast = computed(() => documentsList.value[0] || null);
|
||
const docPendentes = computed(() =>
|
||
documentsList.value.filter((d) => d.status_revisao === 'pendente').length
|
||
);
|
||
function fmtSize(bytes) {
|
||
const b = Number(bytes) || 0;
|
||
if (b < 1024) return `${b} B`;
|
||
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
|
||
if (b < 1024 * 1024 * 1024) return `${(b / (1024 * 1024)).toFixed(1)} MB`;
|
||
return `${(b / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||
}
|
||
const docSizeTotal = computed(() => {
|
||
const total = documentsList.value.reduce((acc, d) => acc + (Number(d.tamanho_bytes) || 0), 0);
|
||
return fmtSize(total);
|
||
});
|
||
|
||
// Para renderizar hora HH:MM curta nos cards
|
||
function fmtHourShort(iso) {
|
||
if (!iso) return '';
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return '';
|
||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||
}
|
||
function fmtDayShort(iso) {
|
||
if (!iso) return '';
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return '';
|
||
return d.toLocaleDateString('pt-BR', { weekday: 'short' }).replace('.', '');
|
||
}
|
||
|
||
// ── 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`;
|
||
}
|
||
|
||
// Data relativa pt-BR — usada nos cards do dashboard.
|
||
// "agora", "há 5 min", "há 2 h", "ontem", "há 3 dias", "em 2 dias", "em 3 sem".
|
||
function fmtRelative(iso) {
|
||
if (!iso) return '—';
|
||
const target = new Date(iso).getTime();
|
||
if (Number.isNaN(target)) return '—';
|
||
const diff = target - Date.now(); // negativo = passado
|
||
const abs = Math.abs(diff);
|
||
const past = diff < 0;
|
||
const min = Math.round(abs / 60000);
|
||
if (min < 1) return 'agora';
|
||
if (min < 60) return past ? `há ${min} min` : `em ${min} min`;
|
||
const h = Math.round(min / 60);
|
||
if (h < 24) return past ? `há ${h} h` : `em ${h} h`;
|
||
const d = Math.round(h / 24);
|
||
if (d === 1) return past ? 'ontem' : 'amanhã';
|
||
if (d < 7) return past ? `há ${d} dias` : `em ${d} dias`;
|
||
const w = Math.round(d / 7);
|
||
if (w < 5) return past ? `há ${w} sem` : `em ${w} sem`;
|
||
return fmtDateBR(iso);
|
||
}
|
||
|
||
// ── 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; }
|
||
}
|
||
|
||
// Mensagens (in+out) — alimenta card "Últimas mensagens" do Visão Geral
|
||
// (4 primeiras) e os KPIs da aba Conversas (count agregado). Limite 200
|
||
// é suficiente pra estatísticas sem carregar conversa inteira (essa fica
|
||
// no PatientConversationsTab embedded).
|
||
async function loadRecentMessages(patientId) {
|
||
messagesLoading.value = true;
|
||
recentMessages.value = [];
|
||
try {
|
||
const { data, error } = await supabase
|
||
.from('conversation_messages')
|
||
.select('id, body, direction, created_at, channel, kanban_status')
|
||
.eq('patient_id', patientId)
|
||
.order('created_at', { ascending: false })
|
||
.limit(200);
|
||
if (error) throw error;
|
||
recentMessages.value = data || [];
|
||
} catch { recentMessages.value = []; }
|
||
finally { messagesLoading.value = false; }
|
||
}
|
||
|
||
// Documentos do paciente — só os campos pra KPIs (count, tipo, última).
|
||
// Filtra deletados (deleted_at IS NULL) pra coerência com listagem padrão.
|
||
async function loadDocumentsList(patientId) {
|
||
documentsLoading.value = true;
|
||
documentsList.value = [];
|
||
try {
|
||
const { data, error } = await supabase
|
||
.from('documents')
|
||
.select('id, tipo_documento, created_at, status_revisao, tamanho_bytes')
|
||
.eq('patient_id', patientId)
|
||
.is('deleted_at', null)
|
||
.order('created_at', { ascending: false })
|
||
.limit(200);
|
||
if (error) throw error;
|
||
documentsList.value = data || [];
|
||
} catch { documentsList.value = []; }
|
||
finally { documentsLoading.value = false; }
|
||
}
|
||
|
||
// Lançamentos financeiros — alimenta o card de pagamento na Visão Geral
|
||
// e a aba Financeiro completa. Schema: paid_at NULL = pendente, preenchido
|
||
// = pago. "Vencido" = paid_at IS NULL AND due_date < hoje.
|
||
async function loadFinancialRecent(patientId) {
|
||
financialLoading.value = true;
|
||
financialRecords.value = [];
|
||
try {
|
||
const { data, error } = await supabase
|
||
.from('financial_records')
|
||
.select('id, type, amount, due_date, paid_at, description, payment_method, category, created_at')
|
||
.eq('patient_id', patientId)
|
||
.eq('type', 'receita')
|
||
.order('created_at', { ascending: false })
|
||
.limit(100);
|
||
if (error) throw error;
|
||
financialRecords.value = data || [];
|
||
} catch { financialRecords.value = []; }
|
||
finally { financialLoading.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 = [];
|
||
recentMessages.value = [];
|
||
financialRecords.value = [];
|
||
documentsList.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;
|
||
// Registra no "recentemente acessados" (localStorage)
|
||
try { await registerPatientVisit(p); } catch { /* ignore */ }
|
||
const [g, t] = await Promise.all([
|
||
getGroupsByIds(rel.groupIds || []),
|
||
getTagsByIds(rel.tagIds || []),
|
||
loadSessions(id),
|
||
loadRecentMessages(id),
|
||
loadFinancialRecent(id),
|
||
loadDocumentsList(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));
|
||
}
|
||
|
||
// ── AÇÕES rápidas (sidebar) ───────────────────────────────────
|
||
// Status sessão: aplicado à proximaSessao (única que faz sentido aqui;
|
||
// sem proximaSessao os botões ficam disabled). Após update, recarrega
|
||
// sessions pra refletir contagens do dashboard.
|
||
const sessionBusy = ref(false);
|
||
async function setProximaSessaoStatus(novoStatus, msgSucesso) {
|
||
const ev = proximaSessao.value;
|
||
if (!ev?.id || sessionBusy.value) return;
|
||
sessionBusy.value = true;
|
||
try {
|
||
const { error } = await supabase
|
||
.from('agenda_eventos')
|
||
.update({ status: novoStatus })
|
||
.eq('id', ev.id);
|
||
if (error) throw error;
|
||
toast.add({ severity: 'success', summary: msgSucesso, life: 2200 });
|
||
const pid = patientData.value?.id;
|
||
if (pid) await loadSessions(pid);
|
||
} catch (e) {
|
||
toast.add({ severity: 'error', summary: 'Falha ao atualizar', detail: e?.message || 'Erro inesperado', life: 4000 });
|
||
} finally {
|
||
sessionBusy.value = false;
|
||
}
|
||
}
|
||
// Solicita ao parent abrir o dialog/rota de novo lançamento financeiro
|
||
// pra esse paciente. Emit é genérico — parent decide o destino.
|
||
function onAddFinancial() {
|
||
const id = patientData.value?.id;
|
||
emit('add-financial', { patientId: id, patientName: nomeCompleto.value });
|
||
}
|
||
|
||
function onMarcarRealizada() { setProximaSessaoStatus('realizado', 'Sessão marcada como realizada'); }
|
||
function onMarcarFalta() { setProximaSessaoStatus('faltou', 'Marcada como falta'); }
|
||
function onMarcarReagendar() { setProximaSessaoStatus('remarcar', 'Marcada para remarcar'); }
|
||
function onMarcarCancelar() { setProximaSessaoStatus('cancelado', 'Sessão cancelada'); }
|
||
|
||
// Abre conversa do paciente no drawer global. Se o paciente não tem
|
||
// telefone cadastrado, abre o phoneDlg pra capturar — após salvar,
|
||
// continua o fluxo automaticamente via flag `_resumeDrawerAfterPhone`.
|
||
async function abrirWhatsappPaciente() {
|
||
const pid = patientData.value?.id;
|
||
if (!pid) return;
|
||
if (!telefone.value) {
|
||
_resumeDrawerAfterPhone.value = true;
|
||
openAddPhone();
|
||
return;
|
||
}
|
||
await conversationDrawerStore.openForPatient(String(pid));
|
||
if (!conversationDrawerStore.isOpen) {
|
||
const detail = conversationDrawerStore.error?.message || 'Não foi possível abrir a conversa.';
|
||
toast.add({ severity: 'warn', summary: 'WhatsApp', detail, life: 3500 });
|
||
conversationDrawerStore.error = null;
|
||
}
|
||
}
|
||
const _resumeDrawerAfterPhone = ref(false);
|
||
|
||
// Vai pra aba "Perfil" e abre o accordion de Sessões (item value="5").
|
||
function abrirSessoesPaciente() {
|
||
activeTab.value = 1;
|
||
activeAccValues.value = ['5'];
|
||
nextTick(() => {
|
||
const el = panelHeaderRefs.value?.[5]?.$el ?? panelHeaderRefs.value?.[5];
|
||
if (el?.scrollIntoView) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
});
|
||
}
|
||
|
||
// Slug do status da próxima sessão pra destacar botão ativo
|
||
const proximaStatusSlug = computed(() => {
|
||
const s = String(proximaSessao.value?.status || '').toLowerCase();
|
||
if (s === 'realizada') return 'realizado';
|
||
if (s === 'cancelada') return 'cancelado';
|
||
return s;
|
||
});
|
||
const temProximaSessao = computed(() => !!proximaSessao.value?.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:title:class="text-[0.95rem] font-bold text-[var(--text-color)] truncate"
|
||
@hide="close"
|
||
>
|
||
<!-- ── HEADER DO DIALOG ────────────────────────────────── -->
|
||
<!-- Compacto: max 2 linhas, altura padrão do Dialog. Sem botões
|
||
extras no header — ações ficam dentro do Dashboard. -->
|
||
<template #header>
|
||
<div class="pp-head">
|
||
<div v-if="avatarUrl" class="pp-head__avatar">
|
||
<img :src="avatarUrl" alt="" />
|
||
</div>
|
||
<div v-else class="pp-head__avatar pp-head__avatar--initials">
|
||
<span>{{ avatarInitials }}</span>
|
||
</div>
|
||
<div class="pp-head__id">
|
||
<div class="pp-head__line1">
|
||
<span class="pp-head__name">{{ dash(nomeCompleto) }}</span>
|
||
<span v-if="riscoElevado" class="pp-head__pill pp-head__pill--danger">
|
||
<i class="pi pi-exclamation-circle" /> risco
|
||
</span>
|
||
</div>
|
||
<div class="pp-head__line2">
|
||
<span>{{ ageLabel }}</span>
|
||
<span v-if="patientData.pronomes" class="pp-head__sep">·</span>
|
||
<span v-if="patientData.pronomes">{{ patientData.pronomes }}</span>
|
||
<span v-if="convenio" class="pp-head__sep">·</span>
|
||
<span v-if="convenio" class="pp-head__chip">{{ convenio }}</span>
|
||
</div>
|
||
</div>
|
||
</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">
|
||
<div class="text-sm font-bold text-[var(--text-color)] truncate">
|
||
{{ dash(nomeCompleto) }}
|
||
</div>
|
||
<div class="text-xs text-[var(--text-color-secondary)] mt-0.5">
|
||
{{ ageLabel }}
|
||
<template v-if="patientData.pronomes"> · {{ patientData.pronomes }}</template>
|
||
</div>
|
||
<div v-if="naturalidade || estado"
|
||
class="text-xs text-[var(--text-color-secondary)]">
|
||
{{ dash(naturalidade) }}<template v-if="estado">, {{ estado }}</template>
|
||
</div>
|
||
<!-- 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>
|
||
|
||
<!-- Ações rápidas — espelham o MelissaEventoPanel:
|
||
status da próxima sessão (Realizada/Falta/Reagendar/
|
||
Cancelar) + atalhos (Sessões/Conversar/Editar). -->
|
||
<div class="pp-actions">
|
||
<section class="pp-actions__section">
|
||
<div class="pp-actions__label">
|
||
Marcar próxima sessão como:
|
||
<span v-if="!temProximaSessao" class="pp-actions__hint">(sem sessão futura)</span>
|
||
</div>
|
||
<div class="pp-actions__group">
|
||
<button
|
||
type="button"
|
||
class="pp-act pp-act--ok"
|
||
:class="{ 'is-current': proximaStatusSlug === 'realizado' }"
|
||
:disabled="!temProximaSessao || sessionBusy"
|
||
@click="onMarcarRealizada"
|
||
>
|
||
<i class="pi pi-check-circle" />
|
||
<span class="pp-act__label">Realizada</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="pp-act pp-act--warn"
|
||
:class="{ 'is-current': proximaStatusSlug === 'faltou' }"
|
||
:disabled="!temProximaSessao || sessionBusy"
|
||
@click="onMarcarFalta"
|
||
>
|
||
<i class="pi pi-user-minus" />
|
||
<span class="pp-act__label">Falta</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="pp-act"
|
||
:class="{ 'is-current': proximaStatusSlug === 'remarcar' || proximaStatusSlug === 'remarcado' }"
|
||
:disabled="!temProximaSessao || sessionBusy"
|
||
@click="onMarcarReagendar"
|
||
>
|
||
<i class="pi pi-calendar-clock" />
|
||
<span class="pp-act__label">Reagendar</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="pp-act pp-act--danger"
|
||
:class="{ 'is-current': proximaStatusSlug === 'cancelado' }"
|
||
:disabled="!temProximaSessao || sessionBusy"
|
||
@click="onMarcarCancelar"
|
||
>
|
||
<i class="pi pi-ban" />
|
||
<span class="pp-act__label">Cancelar</span>
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="pp-actions__section">
|
||
<div class="pp-actions__group">
|
||
<button type="button" class="pp-act" @click="abrirSessoesPaciente">
|
||
<i class="pi pi-history" />
|
||
<span class="pp-act__label">Sessões</span>
|
||
</button>
|
||
<button type="button" class="pp-act" @click="abrirWhatsappPaciente">
|
||
<i class="pi pi-whatsapp" />
|
||
<span class="pp-act__label">Conversar</span>
|
||
</button>
|
||
<button type="button" class="pp-act" @click="editPatient">
|
||
<i class="pi pi-pencil" />
|
||
<span class="pp-act__label">Editar</span>
|
||
</button>
|
||
</div>
|
||
</section>
|
||
</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 === 1) 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 === 1 && !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="pp-main min-w-0 rounded-xl border border-[var(--surface-border)]
|
||
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 === 1) activeAccValues = ['0']"
|
||
>
|
||
<i :class="tab.icon" class="text-sm" />
|
||
{{ tab.label }}
|
||
</button>
|
||
</div>
|
||
|
||
<!-- ═══════════════════════════════════════════
|
||
ABA 0: VISÃO GERAL — dashboard resumo
|
||
═══════════════════════════════════════════ -->
|
||
<div v-show="activeTab === 0" class="pp-overview">
|
||
<div class="pp-ov__bg" aria-hidden="true">
|
||
<span class="pp-ov__spot pp-ov__spot--a" />
|
||
<span class="pp-ov__spot pp-ov__spot--b" />
|
||
<span class="pp-ov__line pp-ov__line--1" />
|
||
<span class="pp-ov__line pp-ov__line--2" />
|
||
</div>
|
||
|
||
<div class="pp-ov__inner">
|
||
<!-- ── KPI cards ─────────────────────────── -->
|
||
<div class="pp-kpis">
|
||
<!-- 1. Sessões presentes -->
|
||
<article class="pp-kpi" style="--c:#4ade80; --c-dim:rgba(74,222,128,0.1); --c-border:rgba(74,222,128,0.3)">
|
||
<div class="pp-kpi__shine" />
|
||
<span class="pp-kpi__num">01</span>
|
||
<header class="pp-kpi__head">
|
||
<div class="pp-kpi__icon"><i class="pi pi-check-circle" /></div>
|
||
<span class="pp-kpi__tag">Sessões</span>
|
||
</header>
|
||
<div class="pp-kpi__body">
|
||
<div class="pp-kpi__big">{{ sessoesPresentes }}</div>
|
||
<div class="pp-kpi__cap">
|
||
de {{ sessions.length }} totais
|
||
<span v-if="sessoesFaltadas" class="pp-kpi__cap-warn">· {{ sessoesFaltadas }} {{ sessoesFaltadas === 1 ? 'falta' : 'faltas' }}</span>
|
||
<span v-if="sessoesCanceladas" class="pp-kpi__cap-dim">· {{ sessoesCanceladas }} cancel.</span>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
|
||
<!-- 2. Pagamento -->
|
||
<article class="pp-kpi"
|
||
:style="statusFinanceiro.emDia === false
|
||
? '--c:#f87171; --c-dim:rgba(248,113,113,0.10); --c-border:rgba(248,113,113,0.30)'
|
||
: '--c:var(--primary-color); --c-dim:color-mix(in srgb, var(--primary-color) 10%, transparent); --c-border:color-mix(in srgb, var(--primary-color) 30%, transparent)'">
|
||
<div class="pp-kpi__shine" />
|
||
<span class="pp-kpi__num">02</span>
|
||
<header class="pp-kpi__head">
|
||
<div class="pp-kpi__icon"><i class="pi pi-wallet" /></div>
|
||
<span class="pp-kpi__tag">Pagamento</span>
|
||
</header>
|
||
<div class="pp-kpi__body">
|
||
<template v-if="financialLoading">
|
||
<div class="pp-kpi__big pp-kpi__big--small">…</div>
|
||
<div class="pp-kpi__cap">Carregando</div>
|
||
</template>
|
||
<template v-else-if="!financialRecords.length">
|
||
<div class="pp-kpi__big pp-kpi__big--small">—</div>
|
||
<div class="pp-kpi__cap">Sem lançamentos</div>
|
||
</template>
|
||
<template v-else>
|
||
<div class="pp-kpi__big pp-kpi__big--small">
|
||
{{ statusFinanceiro.emDia === false ? 'Em atraso' : 'Em dia' }}
|
||
</div>
|
||
<div class="pp-kpi__cap">
|
||
<template v-if="statusFinanceiro.proxVenc">
|
||
Próx. venc. {{ fmtDateBR(statusFinanceiro.proxVenc.due_date) }}
|
||
<span class="pp-kpi__cap-dim">· {{ fmtRelative(statusFinanceiro.proxVenc.due_date) }}</span>
|
||
</template>
|
||
<template v-else-if="statusFinanceiro.totalPendente > 0">
|
||
{{ fmtCurrency(statusFinanceiro.totalPendente) }} pendente
|
||
</template>
|
||
<template v-else>
|
||
Tudo quitado
|
||
</template>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</article>
|
||
|
||
<!-- 3. Próxima sessão -->
|
||
<article class="pp-kpi" style="--c:#60a5fa; --c-dim:rgba(96,165,250,0.10); --c-border:rgba(96,165,250,0.30)">
|
||
<div class="pp-kpi__shine" />
|
||
<span class="pp-kpi__num">03</span>
|
||
<header class="pp-kpi__head">
|
||
<div class="pp-kpi__icon"><i class="pi pi-calendar-clock" /></div>
|
||
<span class="pp-kpi__tag">Próxima</span>
|
||
</header>
|
||
<div class="pp-kpi__body">
|
||
<template v-if="proximaSessao">
|
||
<div class="pp-kpi__big pp-kpi__big--small">{{ fmtRelative(proximaSessao.inicio_em) }}</div>
|
||
<div class="pp-kpi__cap">
|
||
{{ fmtDateTimeBR(proximaSessao.inicio_em) }}
|
||
<span v-if="proximaSessao.modalidade" class="pp-kpi__cap-dim">· {{ proximaSessao.modalidade === 'online' ? 'Online' : 'Presencial' }}</span>
|
||
</div>
|
||
</template>
|
||
<template v-else>
|
||
<div class="pp-kpi__big pp-kpi__big--small">—</div>
|
||
<div class="pp-kpi__cap">Sem sessão futura agendada</div>
|
||
</template>
|
||
</div>
|
||
</article>
|
||
|
||
<!-- 4. Mensagens -->
|
||
<article class="pp-kpi" style="--c:#34d399; --c-dim:rgba(52,211,153,0.10); --c-border:rgba(52,211,153,0.30)">
|
||
<div class="pp-kpi__shine" />
|
||
<span class="pp-kpi__num">04</span>
|
||
<header class="pp-kpi__head">
|
||
<div class="pp-kpi__icon"><i class="pi pi-comments" /></div>
|
||
<span class="pp-kpi__tag">Mensagens</span>
|
||
</header>
|
||
<div class="pp-kpi__body">
|
||
<template v-if="messagesLoading">
|
||
<div class="pp-kpi__big pp-kpi__big--small">…</div>
|
||
<div class="pp-kpi__cap">Carregando</div>
|
||
</template>
|
||
<template v-else-if="ultimaMensagem">
|
||
<div class="pp-kpi__big pp-kpi__big--small">{{ fmtRelative(ultimaMensagem.created_at) }}</div>
|
||
<div class="pp-kpi__cap">
|
||
Última {{ ultimaMensagem.direction === 'inbound' ? 'recebida' : 'enviada' }}
|
||
<span class="pp-kpi__cap-dim">· {{ recentMessages.length }} no histórico</span>
|
||
</div>
|
||
</template>
|
||
<template v-else>
|
||
<div class="pp-kpi__big pp-kpi__big--small">—</div>
|
||
<div class="pp-kpi__cap">Nenhuma conversa registrada</div>
|
||
</template>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
|
||
<!-- ── Painéis: Timeline + Mensagens recentes ── -->
|
||
<div class="pp-grid">
|
||
<!-- Timeline -->
|
||
<section class="pp-panel">
|
||
<header class="pp-panel__head">
|
||
<div class="pp-panel__title"><i class="pi pi-history" /> Timeline de atendimentos</div>
|
||
<span class="pp-panel__badge">{{ ultimasAtendidas.length }}</span>
|
||
</header>
|
||
<div class="pp-panel__body">
|
||
<div v-if="sessionsLoading" class="pp-empty">Carregando…</div>
|
||
<div v-else-if="!ultimasAtendidas.length" class="pp-empty pp-empty--rich">
|
||
<div class="pp-empty__icon"><i class="pi pi-history" /></div>
|
||
<div class="pp-empty__title">Sem atendimentos registrados</div>
|
||
<div class="pp-empty__sub">As sessões realizadas com este paciente aparecerão aqui em ordem cronológica.</div>
|
||
</div>
|
||
<ol v-else class="pp-timeline">
|
||
<li
|
||
v-for="s in ultimasAtendidas"
|
||
:key="s.id"
|
||
class="pp-tl"
|
||
:data-status="String(s.status || 'agendado').toLowerCase()"
|
||
>
|
||
<span class="pp-tl__dot" />
|
||
<div class="pp-tl__main">
|
||
<div class="pp-tl__top">
|
||
<span class="pp-tl__when">{{ fmtDateTimeBR(s.inicio_em) }}</span>
|
||
<span class="pp-tl__rel">{{ fmtRelative(s.inicio_em) }}</span>
|
||
</div>
|
||
<div class="pp-tl__row">
|
||
<Tag
|
||
:value="STATUS_LABEL[s.status] || s.status || '—'"
|
||
:severity="STATUS_SEVERITY[s.status] || 'info'"
|
||
class="pp-tl__tag"
|
||
/>
|
||
<span v-if="s.modalidade" class="pp-tl__chip">
|
||
<i :class="s.modalidade === 'online' ? 'pi pi-video' : 'pi pi-map-marker'" />
|
||
{{ s.modalidade === 'online' ? 'Online' : 'Presencial' }}
|
||
</span>
|
||
<span v-if="sessionDuration(s.inicio_em, s.fim_em)" class="pp-tl__chip pp-tl__chip--dim">
|
||
<i class="pi pi-clock" /> {{ sessionDuration(s.inicio_em, s.fim_em) }}
|
||
</span>
|
||
</div>
|
||
<p v-if="s.observacoes" class="pp-tl__note">{{ s.observacoes }}</p>
|
||
</div>
|
||
</li>
|
||
</ol>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Mensagens recentes + Notas internas -->
|
||
<div class="pp-grid__col">
|
||
<section class="pp-panel">
|
||
<header class="pp-panel__head">
|
||
<div class="pp-panel__title"><i class="pi pi-comments" /> Últimas mensagens</div>
|
||
<span class="pp-panel__badge">{{ recentMessages.length }}</span>
|
||
</header>
|
||
<div class="pp-panel__body">
|
||
<div v-if="messagesLoading" class="pp-empty">Carregando…</div>
|
||
<div v-else-if="!recentMessages.length" class="pp-empty pp-empty--rich">
|
||
<div class="pp-empty__icon"><i class="pi pi-comments" /></div>
|
||
<div class="pp-empty__title">Sem conversa registrada</div>
|
||
<div class="pp-empty__sub">As últimas mensagens trocadas com o paciente aparecerão aqui.</div>
|
||
</div>
|
||
<ul v-else class="pp-msgs">
|
||
<li v-for="m in recentMessages.slice(0, 4)" :key="m.id"
|
||
class="pp-msg"
|
||
:class="m.direction === 'inbound' ? 'pp-msg--in' : 'pp-msg--out'"
|
||
>
|
||
<div class="pp-msg__meta">
|
||
<i :class="m.direction === 'inbound' ? 'pi pi-arrow-down-left' : 'pi pi-arrow-up-right'" />
|
||
<span>{{ m.direction === 'inbound' ? 'Recebida' : 'Enviada' }}</span>
|
||
<span class="pp-msg__sep">·</span>
|
||
<span>{{ fmtRelative(m.created_at) }}</span>
|
||
</div>
|
||
<p class="pp-msg__body">{{ m.body || '—' }}</p>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</section>
|
||
|
||
<section v-if="notasInternas || observacoes" class="pp-panel">
|
||
<header class="pp-panel__head">
|
||
<div class="pp-panel__title"><i class="pi pi-file-edit" /> Notas e observações</div>
|
||
</header>
|
||
<div class="pp-panel__body pp-notes">
|
||
<div v-if="observacoes" class="pp-note">
|
||
<p class="pp-note__label">Observações gerais</p>
|
||
<p class="pp-note__text">{{ observacoes }}</p>
|
||
</div>
|
||
<div v-if="notasInternas" class="pp-note">
|
||
<p class="pp-note__label">
|
||
<i class="pi pi-lock" /> Internas
|
||
</p>
|
||
<p class="pp-note__text">{{ notasInternas }}</p>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ═══════════════════════════════════════════
|
||
ABA 1: PERFIL — accordion
|
||
═══════════════════════════════════════════ -->
|
||
<div v-show="activeTab === 1">
|
||
|
||
<!-- 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 -->
|
||
<!-- ═══════════════════════════════════════════
|
||
ABA 2: PRONTUÁRIO EVOLUTIVO — em breve
|
||
═══════════════════════════════════════════ -->
|
||
<div v-show="activeTab === 2" class="pp-pron">
|
||
<div class="pp-empty pp-empty--rich">
|
||
<div class="pp-empty__icon"><i class="pi pi-file-edit" /></div>
|
||
<div class="pp-empty__title">Prontuário evolutivo</div>
|
||
<div class="pp-empty__sub">Em breve — anamnese, evolução das sessões e plano terapêutico em um só lugar, com histórico clínico completo do paciente.</div>
|
||
</div>
|
||
</div>
|
||
<!-- ═══════════════════════════════════════════
|
||
ABA 3: AGENDA — sessões agrupadas por mês
|
||
═══════════════════════════════════════════ -->
|
||
<div v-show="activeTab === 3" class="pp-ag">
|
||
<div v-if="sessionsLoading" class="pp-empty">
|
||
<i class="pi pi-spin pi-spinner" /> Carregando…
|
||
</div>
|
||
<template v-else>
|
||
<!-- KPIs -->
|
||
<div class="pp-ag__kpis">
|
||
<article class="pp-kpi" style="--c:var(--primary-color); --c-dim:color-mix(in srgb, var(--primary-color) 10%, transparent); --c-border:color-mix(in srgb, var(--primary-color) 30%, transparent)">
|
||
<div class="pp-kpi__shine" />
|
||
<header class="pp-kpi__head">
|
||
<div class="pp-kpi__icon"><i class="pi pi-calendar" /></div>
|
||
<span class="pp-kpi__tag">Total</span>
|
||
</header>
|
||
<div class="pp-kpi__body">
|
||
<div class="pp-kpi__big">{{ sessions.length }}</div>
|
||
<div class="pp-kpi__cap">sessões registradas</div>
|
||
</div>
|
||
</article>
|
||
<article class="pp-kpi" style="--c:#4ade80; --c-dim:rgba(74,222,128,0.10); --c-border:rgba(74,222,128,0.30)">
|
||
<div class="pp-kpi__shine" />
|
||
<header class="pp-kpi__head">
|
||
<div class="pp-kpi__icon"><i class="pi pi-check-circle" /></div>
|
||
<span class="pp-kpi__tag">Realizadas</span>
|
||
</header>
|
||
<div class="pp-kpi__body">
|
||
<div class="pp-kpi__big">{{ sessoesPresentes }}</div>
|
||
<div class="pp-kpi__cap">
|
||
<template v-if="sessions.length">{{ Math.round((sessoesPresentes / sessions.length) * 100) }}% do total</template>
|
||
<template v-else>—</template>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
<article class="pp-kpi" :style="sessoesFaltadas > 0
|
||
? '--c:#f87171; --c-dim:rgba(248,113,113,0.10); --c-border:rgba(248,113,113,0.30)'
|
||
: '--c:#94a3b8; --c-dim:rgba(148,163,184,0.10); --c-border:rgba(148,163,184,0.25)'">
|
||
<div class="pp-kpi__shine" />
|
||
<header class="pp-kpi__head">
|
||
<div class="pp-kpi__icon"><i class="pi pi-user-minus" /></div>
|
||
<span class="pp-kpi__tag">Faltas</span>
|
||
</header>
|
||
<div class="pp-kpi__body">
|
||
<div class="pp-kpi__big">{{ sessoesFaltadas }}</div>
|
||
<div class="pp-kpi__cap">
|
||
<template v-if="sessoesCanceladas">+ {{ sessoesCanceladas }} cancel.</template>
|
||
<template v-else>nenhuma falta</template>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
<article class="pp-kpi" style="--c:#60a5fa; --c-dim:rgba(96,165,250,0.10); --c-border:rgba(96,165,250,0.30)">
|
||
<div class="pp-kpi__shine" />
|
||
<header class="pp-kpi__head">
|
||
<div class="pp-kpi__icon"><i class="pi pi-calendar-clock" /></div>
|
||
<span class="pp-kpi__tag">Próxima</span>
|
||
</header>
|
||
<div class="pp-kpi__body">
|
||
<template v-if="proximaSessao">
|
||
<div class="pp-kpi__big pp-kpi__big--small">{{ fmtRelative(proximaSessao.inicio_em) }}</div>
|
||
<div class="pp-kpi__cap">{{ fmtDateBR(proximaSessao.inicio_em) }} · {{ fmtHourShort(proximaSessao.inicio_em) }}</div>
|
||
</template>
|
||
<template v-else>
|
||
<div class="pp-kpi__big pp-kpi__big--small">—</div>
|
||
<div class="pp-kpi__cap">Sem futura</div>
|
||
</template>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
|
||
<!-- Filtros -->
|
||
<div class="pp-ag__filters" role="tablist">
|
||
<button
|
||
v-for="f in agendaFilters" :key="f.value"
|
||
type="button"
|
||
role="tab"
|
||
:aria-selected="agendaFilter === f.value"
|
||
class="pp-ag__filter"
|
||
:class="{ 'is-active': agendaFilter === f.value }"
|
||
@click="agendaFilter = f.value"
|
||
>
|
||
<i :class="f.icon" />
|
||
<span>{{ f.label }}</span>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Lista agrupada por mês -->
|
||
<div v-if="!agendaAgrupadas.length" class="pp-empty pp-empty--rich">
|
||
<div class="pp-empty__icon"><i class="pi pi-calendar-times" /></div>
|
||
<div class="pp-empty__title">{{ sessions.length ? 'Nenhuma sessão neste filtro' : 'Sem sessões registradas' }}</div>
|
||
<div class="pp-empty__sub">
|
||
<template v-if="sessions.length">Tente outro filtro acima ou veja "Todas" pra listar o histórico completo.</template>
|
||
<template v-else>As sessões agendadas e atendidas com este paciente aparecerão aqui.</template>
|
||
</div>
|
||
</div>
|
||
<section
|
||
v-for="g in agendaAgrupadas" :key="g.key"
|
||
class="pp-panel pp-ag__group"
|
||
>
|
||
<header class="pp-panel__head">
|
||
<div class="pp-panel__title"><i class="pi pi-calendar" /> {{ g.label }}</div>
|
||
<span class="pp-panel__badge">{{ g.items.length }}</span>
|
||
</header>
|
||
<ul class="pp-ag__list">
|
||
<li
|
||
v-for="s in g.items" :key="s.id"
|
||
class="pp-ag__item"
|
||
:data-status="String(s.status || 'agendado').toLowerCase()"
|
||
>
|
||
<div class="pp-ag__date">
|
||
<span class="pp-ag__date-dow">{{ fmtDayShort(s.inicio_em) }}</span>
|
||
<span class="pp-ag__date-day">{{ new Date(s.inicio_em).getDate() }}</span>
|
||
<span class="pp-ag__date-time">{{ fmtHourShort(s.inicio_em) }}</span>
|
||
</div>
|
||
<div class="pp-ag__main">
|
||
<div class="pp-ag__top">
|
||
<Tag
|
||
:value="STATUS_LABEL[s.status] || s.status || 'Agendado'"
|
||
:severity="STATUS_SEVERITY[s.status] || 'info'"
|
||
class="pp-ag__tag"
|
||
/>
|
||
<span v-if="s.modalidade" class="pp-ag__chip">
|
||
<i :class="s.modalidade === 'online' ? 'pi pi-video' : 'pi pi-map-marker'" />
|
||
{{ s.modalidade === 'online' ? 'Online' : 'Presencial' }}
|
||
</span>
|
||
<span v-if="sessionDuration(s.inicio_em, s.fim_em)" class="pp-ag__chip pp-ag__chip--dim">
|
||
<i class="pi pi-clock" /> {{ sessionDuration(s.inicio_em, s.fim_em) }}
|
||
</span>
|
||
<span class="pp-ag__rel">{{ fmtRelative(s.inicio_em) }}</span>
|
||
</div>
|
||
<div v-if="s.titulo_custom || s.titulo" class="pp-ag__title">
|
||
{{ s.titulo_custom || s.titulo }}
|
||
</div>
|
||
<p v-if="s.observacoes" class="pp-ag__note">{{ s.observacoes }}</p>
|
||
</div>
|
||
<div class="pp-ag__actions">
|
||
<button type="button"
|
||
v-tooltip.left="'Marcar como realizada'"
|
||
class="pp-ag__act pp-ag__act--ok"
|
||
:disabled="sessionBusy"
|
||
@click="updateSessionStatus(s, 'realizado', 'Sessão marcada como realizada')">
|
||
<i class="pi pi-check-circle" />
|
||
</button>
|
||
<button type="button"
|
||
v-tooltip.left="'Marcar como falta'"
|
||
class="pp-ag__act pp-ag__act--warn"
|
||
:disabled="sessionBusy"
|
||
@click="updateSessionStatus(s, 'faltou', 'Marcada como falta')">
|
||
<i class="pi pi-user-minus" />
|
||
</button>
|
||
<button type="button"
|
||
v-tooltip.left="'Cancelar'"
|
||
class="pp-ag__act pp-ag__act--danger"
|
||
:disabled="sessionBusy"
|
||
@click="updateSessionStatus(s, 'cancelado', 'Sessão cancelada')">
|
||
<i class="pi pi-ban" />
|
||
</button>
|
||
</div>
|
||
</li>
|
||
</ul>
|
||
</section>
|
||
</template>
|
||
</div>
|
||
<!-- ═══════════════════════════════════════════
|
||
ABA 4: FINANCEIRO — KPIs + tabela
|
||
═══════════════════════════════════════════ -->
|
||
<div v-show="activeTab === 4" class="pp-fin">
|
||
<div v-if="financialLoading" class="pp-empty">
|
||
<i class="pi pi-spin pi-spinner" /> Carregando…
|
||
</div>
|
||
<div v-else-if="!financialRecords.length" class="pp-empty pp-empty--rich">
|
||
<div class="pp-empty__icon"><i class="pi pi-wallet" /></div>
|
||
<div class="pp-empty__title">Sem lançamentos financeiros</div>
|
||
<div class="pp-empty__sub">Adicione o primeiro lançamento de cobrança ou recebimento deste paciente.</div>
|
||
<Button
|
||
label="Novo lançamento"
|
||
icon="pi pi-plus"
|
||
class="pp-empty__cta rounded-full"
|
||
@click="onAddFinancial"
|
||
/>
|
||
</div>
|
||
<template v-else>
|
||
<!-- KPIs do financeiro -->
|
||
<div class="pp-fin__kpis">
|
||
<article class="pp-kpi" style="--c:#4ade80; --c-dim:rgba(74,222,128,0.10); --c-border:rgba(74,222,128,0.30)">
|
||
<div class="pp-kpi__shine" />
|
||
<header class="pp-kpi__head">
|
||
<div class="pp-kpi__icon"><i class="pi pi-check-circle" /></div>
|
||
<span class="pp-kpi__tag">Pago</span>
|
||
</header>
|
||
<div class="pp-kpi__body">
|
||
<div class="pp-kpi__big pp-kpi__big--small">{{ fmtCurrency(statusFinanceiro.totalPago) }}</div>
|
||
<div class="pp-kpi__cap">
|
||
{{ financialRecords.filter((r) => !!r.paid_at).length }} {{ financialRecords.filter((r) => !!r.paid_at).length === 1 ? 'lançamento' : 'lançamentos' }}
|
||
</div>
|
||
</div>
|
||
</article>
|
||
|
||
<article class="pp-kpi" style="--c:var(--primary-color); --c-dim:color-mix(in srgb, var(--primary-color) 10%, transparent); --c-border:color-mix(in srgb, var(--primary-color) 30%, transparent)">
|
||
<div class="pp-kpi__shine" />
|
||
<header class="pp-kpi__head">
|
||
<div class="pp-kpi__icon"><i class="pi pi-clock" /></div>
|
||
<span class="pp-kpi__tag">Pendente</span>
|
||
</header>
|
||
<div class="pp-kpi__body">
|
||
<div class="pp-kpi__big pp-kpi__big--small">{{ fmtCurrency(statusFinanceiro.totalPendente) }}</div>
|
||
<div class="pp-kpi__cap">
|
||
<template v-if="statusFinanceiro.proxVenc">
|
||
Próx. venc. {{ fmtDateBR(statusFinanceiro.proxVenc.due_date) }}
|
||
</template>
|
||
<template v-else>A receber</template>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
|
||
<article class="pp-kpi" :style="statusFinanceiro.vencidos > 0
|
||
? '--c:#f87171; --c-dim:rgba(248,113,113,0.10); --c-border:rgba(248,113,113,0.30)'
|
||
: '--c:#94a3b8; --c-dim:rgba(148,163,184,0.10); --c-border:rgba(148,163,184,0.25)'">
|
||
<div class="pp-kpi__shine" />
|
||
<header class="pp-kpi__head">
|
||
<div class="pp-kpi__icon"><i class="pi pi-exclamation-triangle" /></div>
|
||
<span class="pp-kpi__tag">Em atraso</span>
|
||
</header>
|
||
<div class="pp-kpi__body">
|
||
<div class="pp-kpi__big pp-kpi__big--small">
|
||
{{ statusFinanceiro.vencidos }}
|
||
</div>
|
||
<div class="pp-kpi__cap">
|
||
{{ statusFinanceiro.vencidos === 0 ? 'Tudo em dia' : (statusFinanceiro.vencidos === 1 ? 'lançamento vencido' : 'lançamentos vencidos') }}
|
||
</div>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
|
||
<!-- Tabela de lançamentos -->
|
||
<section class="pp-panel pp-fin__list">
|
||
<header class="pp-panel__head">
|
||
<div class="pp-panel__title"><i class="pi pi-list" /> Lançamentos</div>
|
||
<span class="pp-panel__badge">{{ recordsOrdenados.length }}</span>
|
||
</header>
|
||
<div class="pp-fin__table" role="table">
|
||
<div class="pp-fin__row pp-fin__row--head" role="row">
|
||
<span role="columnheader">Vencimento</span>
|
||
<span role="columnheader">Descrição</span>
|
||
<span role="columnheader">Forma</span>
|
||
<span role="columnheader" class="pp-fin__col-amount">Valor</span>
|
||
<span role="columnheader">Status</span>
|
||
</div>
|
||
<div
|
||
v-for="r in recordsOrdenados" :key="r.id"
|
||
class="pp-fin__row"
|
||
:data-status="recordStatus(r)"
|
||
role="row"
|
||
>
|
||
<span class="pp-fin__date" role="cell">
|
||
<span class="pp-fin__date-main">{{ r.due_date ? fmtDateBR(r.due_date) : fmtDateBR(r.created_at) }}</span>
|
||
<span v-if="r.due_date" class="pp-fin__date-rel">{{ fmtRelative(r.due_date) }}</span>
|
||
</span>
|
||
<span class="pp-fin__desc" role="cell">
|
||
{{ r.description || (r.category ? r.category : 'Lançamento') }}
|
||
</span>
|
||
<span class="pp-fin__method" role="cell">
|
||
{{ fmtPaymentMethod(r.payment_method) || '—' }}
|
||
</span>
|
||
<span class="pp-fin__amount" role="cell">
|
||
{{ fmtCurrency(Number(r.amount) || 0) }}
|
||
</span>
|
||
<span class="pp-fin__status-cell" role="cell">
|
||
<span class="pp-fin__status" :data-status="recordStatus(r)">
|
||
<span class="pp-fin__status-dot" />
|
||
{{ RECORD_STATUS_LABEL[recordStatus(r)] }}
|
||
</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
</div>
|
||
<!-- ═══════════════════════════════════════════
|
||
ABA 5: DOCUMENTOS — KPIs + DocumentsListPage embedded
|
||
═══════════════════════════════════════════ -->
|
||
<div v-show="activeTab === 5" class="pp-doc">
|
||
<!-- KPIs (só se tiver algo) -->
|
||
<div v-if="documentsLoading" class="pp-empty">
|
||
<i class="pi pi-spin pi-spinner" /> Carregando…
|
||
</div>
|
||
<div v-else-if="docTotal" class="pp-doc__kpis">
|
||
<article class="pp-kpi" style="--c:var(--primary-color); --c-dim:color-mix(in srgb, var(--primary-color) 10%, transparent); --c-border:color-mix(in srgb, var(--primary-color) 30%, transparent)">
|
||
<div class="pp-kpi__shine" />
|
||
<header class="pp-kpi__head">
|
||
<div class="pp-kpi__icon"><i class="pi pi-folder" /></div>
|
||
<span class="pp-kpi__tag">Total</span>
|
||
</header>
|
||
<div class="pp-kpi__body">
|
||
<div class="pp-kpi__big">{{ docTotal }}</div>
|
||
<div class="pp-kpi__cap">{{ docSizeTotal }} no total</div>
|
||
</div>
|
||
</article>
|
||
|
||
<article v-if="docTopType" class="pp-kpi" style="--c:#0ea5e9; --c-dim:rgba(14,165,233,0.10); --c-border:rgba(14,165,233,0.30)">
|
||
<div class="pp-kpi__shine" />
|
||
<header class="pp-kpi__head">
|
||
<div class="pp-kpi__icon"><i class="pi pi-tag" /></div>
|
||
<span class="pp-kpi__tag">Mais comum</span>
|
||
</header>
|
||
<div class="pp-kpi__body">
|
||
<div class="pp-kpi__big pp-kpi__big--small">{{ docTopType.label }}</div>
|
||
<div class="pp-kpi__cap">{{ docTopType.count }} {{ docTopType.count === 1 ? 'documento' : 'documentos' }}</div>
|
||
</div>
|
||
</article>
|
||
|
||
<article v-if="docLast" class="pp-kpi" style="--c:#4ade80; --c-dim:rgba(74,222,128,0.10); --c-border:rgba(74,222,128,0.30)">
|
||
<div class="pp-kpi__shine" />
|
||
<header class="pp-kpi__head">
|
||
<div class="pp-kpi__icon"><i class="pi pi-clock" /></div>
|
||
<span class="pp-kpi__tag">Último</span>
|
||
</header>
|
||
<div class="pp-kpi__body">
|
||
<div class="pp-kpi__big pp-kpi__big--small">{{ fmtRelative(docLast.created_at) }}</div>
|
||
<div class="pp-kpi__cap">{{ fmtDateBR(docLast.created_at) }}</div>
|
||
</div>
|
||
</article>
|
||
|
||
<article v-if="docPendentes > 0" class="pp-kpi" style="--c:#f59e0b; --c-dim:rgba(245,158,11,0.10); --c-border:rgba(245,158,11,0.30)">
|
||
<div class="pp-kpi__shine" />
|
||
<header class="pp-kpi__head">
|
||
<div class="pp-kpi__icon"><i class="pi pi-exclamation-circle" /></div>
|
||
<span class="pp-kpi__tag">Revisão</span>
|
||
</header>
|
||
<div class="pp-kpi__body">
|
||
<div class="pp-kpi__big">{{ docPendentes }}</div>
|
||
<div class="pp-kpi__cap">{{ docPendentes === 1 ? 'pendente' : 'pendentes' }}</div>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
|
||
<!-- Lista (DocumentsListPage embedded) dentro de um panel -->
|
||
<section class="pp-panel pp-doc__list">
|
||
<DocumentsListPage
|
||
:patientId="patient?.id"
|
||
:patientName="patient?.nome_completo || patient?.nome_social || ''"
|
||
embedded
|
||
/>
|
||
</section>
|
||
</div>
|
||
<!-- ═══════════════════════════════════════════
|
||
ABA 6: CONVERSAS — KPIs + PatientConversationsTab
|
||
═══════════════════════════════════════════ -->
|
||
<div v-show="activeTab === 6" class="pp-conv">
|
||
<div v-if="messagesLoading" class="pp-empty">
|
||
<i class="pi pi-spin pi-spinner" /> Carregando…
|
||
</div>
|
||
<template v-else>
|
||
<div v-if="convTotal" class="pp-conv__kpis">
|
||
<article class="pp-kpi" style="--c:#25d366; --c-dim:rgba(37,211,102,0.10); --c-border:rgba(37,211,102,0.30)">
|
||
<div class="pp-kpi__shine" />
|
||
<header class="pp-kpi__head">
|
||
<div class="pp-kpi__icon"><i class="pi pi-comments" /></div>
|
||
<span class="pp-kpi__tag">Mensagens</span>
|
||
</header>
|
||
<div class="pp-kpi__body">
|
||
<div class="pp-kpi__big">{{ convTotal }}</div>
|
||
<div class="pp-kpi__cap">
|
||
<template v-if="convChannels.length">via {{ convChannels.map(chConvLabel).join(', ') }}</template>
|
||
<template v-else>no histórico</template>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
|
||
<article class="pp-kpi" style="--c:#4ade80; --c-dim:rgba(74,222,128,0.10); --c-border:rgba(74,222,128,0.30)">
|
||
<div class="pp-kpi__shine" />
|
||
<header class="pp-kpi__head">
|
||
<div class="pp-kpi__icon"><i class="pi pi-arrow-down-left" /></div>
|
||
<span class="pp-kpi__tag">Recebidas</span>
|
||
</header>
|
||
<div class="pp-kpi__body">
|
||
<div class="pp-kpi__big">{{ convInbound }}</div>
|
||
<div class="pp-kpi__cap">
|
||
<template v-if="convTotal">{{ Math.round((convInbound / convTotal) * 100) }}% do total</template>
|
||
<template v-else>—</template>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
|
||
<article class="pp-kpi" style="--c:#60a5fa; --c-dim:rgba(96,165,250,0.10); --c-border:rgba(96,165,250,0.30)">
|
||
<div class="pp-kpi__shine" />
|
||
<header class="pp-kpi__head">
|
||
<div class="pp-kpi__icon"><i class="pi pi-arrow-up-right" /></div>
|
||
<span class="pp-kpi__tag">Enviadas</span>
|
||
</header>
|
||
<div class="pp-kpi__body">
|
||
<div class="pp-kpi__big">{{ convOutbound }}</div>
|
||
<div class="pp-kpi__cap">
|
||
<template v-if="convTotal">{{ Math.round((convOutbound / convTotal) * 100) }}% do total</template>
|
||
<template v-else>—</template>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
|
||
<article class="pp-kpi" style="--c:var(--primary-color); --c-dim:color-mix(in srgb, var(--primary-color) 10%, transparent); --c-border:color-mix(in srgb, var(--primary-color) 30%, transparent)">
|
||
<div class="pp-kpi__shine" />
|
||
<header class="pp-kpi__head">
|
||
<div class="pp-kpi__icon"><i class="pi pi-clock" /></div>
|
||
<span class="pp-kpi__tag">Última</span>
|
||
</header>
|
||
<div class="pp-kpi__body">
|
||
<template v-if="convLast">
|
||
<div class="pp-kpi__big pp-kpi__big--small">{{ fmtRelative(convLast.created_at) }}</div>
|
||
<div class="pp-kpi__cap">
|
||
{{ convLast.direction === 'inbound' ? 'Recebida' : 'Enviada' }}
|
||
<span v-if="convFirst" class="pp-kpi__cap-dim">· 1ª {{ fmtRelative(convFirst.created_at) }}</span>
|
||
</div>
|
||
</template>
|
||
<template v-else>
|
||
<div class="pp-kpi__big pp-kpi__big--small">—</div>
|
||
<div class="pp-kpi__cap">Sem mensagens</div>
|
||
</template>
|
||
</div>
|
||
</article>
|
||
</div>
|
||
|
||
<!-- Atalho pra abrir o drawer (continuar a conversa) -->
|
||
<div class="pp-conv__cta">
|
||
<Button
|
||
label="Abrir conversa no drawer"
|
||
icon="pi pi-whatsapp"
|
||
severity="success"
|
||
outlined
|
||
class="rounded-full"
|
||
@click="abrirWhatsappPaciente"
|
||
/>
|
||
<span class="pp-conv__cta-hint">Continue a conversa em tempo real sem fechar o prontuário.</span>
|
||
</div>
|
||
|
||
<!-- Histórico completo via componente original -->
|
||
<section class="pp-panel pp-conv__list">
|
||
<PatientConversationsTab
|
||
:patient-id="patient?.id"
|
||
:patient-name="patient?.nome_completo || patient?.nome_social || ''"
|
||
/>
|
||
</section>
|
||
</template>
|
||
</div>
|
||
|
||
</main>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ── FOOTER ──────────────────────────────────────────── -->
|
||
<template #footer>
|
||
<div class="flex items-center justify-between gap-2 flex-wrap px-1">
|
||
<Button
|
||
v-if="activeTab === 1"
|
||
: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>
|
||
|
||
<!-- ConfirmDialog local — garante que confirm.require funciona mesmo
|
||
se o parent que abriu o prontuário não tiver um <ConfirmDialog>
|
||
montado (ex: contexto fora do MelissaLayout). -->
|
||
<ConfirmDialog />
|
||
|
||
<!-- Dialog: adicionar telefone WhatsApp ao paciente.
|
||
Salva em patients.telefone (campo único — usado pelo
|
||
conversationDrawerStore.openForPatient pra criar a thread).
|
||
Se foi acionado por uma tentativa de abrir o drawer (flag
|
||
_resumeDrawerAfterPhone), o drawer abre automaticamente após
|
||
o save bem-sucedido. -->
|
||
<Dialog
|
||
v-model:visible="phoneDlg"
|
||
modal
|
||
:draggable="false"
|
||
:closable="!phoneSaving"
|
||
header="Adicionar telefone WhatsApp"
|
||
class="w-[94vw] max-w-md"
|
||
pt:mask:class="backdrop-blur-sm"
|
||
@hide="_resumeDrawerAfterPhone = false"
|
||
>
|
||
<div class="flex flex-col gap-3 pt-1">
|
||
<FloatLabel variant="on">
|
||
<InputMask
|
||
id="ppd-phone"
|
||
v-model="phoneInput"
|
||
mask="(99) 99999-9999"
|
||
slotChar="_"
|
||
class="w-full"
|
||
autofocus
|
||
@keydown.enter="savePhone"
|
||
/>
|
||
<label for="ppd-phone">Telefone WhatsApp</label>
|
||
</FloatLabel>
|
||
<span class="text-xs text-[var(--text-color-secondary)]">
|
||
<i class="pi pi-info-circle text-[0.7rem] mr-1 opacity-70" />
|
||
DDD + número. Usado pra enviar/receber mensagens via WhatsApp.
|
||
</span>
|
||
</div>
|
||
<template #footer>
|
||
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" :disabled="phoneSaving" @click="phoneDlg = false" />
|
||
<Button label="Salvar" icon="pi pi-check" :loading="phoneSaving" class="rounded-full" @click="savePhone" />
|
||
</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; }
|
||
|
||
/* ════════════════════════════════════════════════════════════════
|
||
HEADER do Dialog — compacto, max 2 linhas, altura padrão.
|
||
Sem #header slot grande; só avatar mini + nome + meta inline.
|
||
════════════════════════════════════════════════════════════════ */
|
||
.pp-head {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
min-width: 0;
|
||
flex: 1;
|
||
}
|
||
.pp-head__avatar {
|
||
flex-shrink: 0;
|
||
width: 32px;
|
||
height: 32px;
|
||
border-radius: 50%;
|
||
overflow: hidden;
|
||
background: linear-gradient(135deg, var(--primary-color), color-mix(in srgb, var(--primary-color) 70%, white));
|
||
display: grid;
|
||
place-items: center;
|
||
}
|
||
.pp-head__avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||
.pp-head__avatar--initials span {
|
||
color: white;
|
||
font-size: 0.7rem;
|
||
font-weight: 700;
|
||
letter-spacing: -0.02em;
|
||
}
|
||
.pp-head__id {
|
||
min-width: 0;
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1px;
|
||
}
|
||
.pp-head__line1 {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
min-width: 0;
|
||
}
|
||
.pp-head__name {
|
||
font-size: 0.92rem;
|
||
font-weight: 700;
|
||
color: var(--text-color);
|
||
letter-spacing: -0.01em;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
min-width: 0;
|
||
}
|
||
.pp-head__line2 {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
font-size: 0.7rem;
|
||
color: var(--text-color-secondary);
|
||
flex-wrap: nowrap;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
min-width: 0;
|
||
}
|
||
.pp-head__sep { opacity: 0.4; }
|
||
.pp-head__chip {
|
||
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
|
||
border: 1px solid color-mix(in srgb, var(--primary-color) 25%, transparent);
|
||
color: var(--primary-color);
|
||
padding: 1px 7px;
|
||
border-radius: 100px;
|
||
font-size: 0.62rem;
|
||
font-weight: 600;
|
||
line-height: 1.4;
|
||
}
|
||
.pp-head__pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 3px;
|
||
padding: 1px 7px;
|
||
border-radius: 100px;
|
||
font-size: 0.6rem;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
line-height: 1.4;
|
||
flex-shrink: 0;
|
||
}
|
||
.pp-head__pill--danger {
|
||
background: rgba(248, 113, 113, 0.12);
|
||
border: 1px solid rgba(248, 113, 113, 0.35);
|
||
color: #b91c1c;
|
||
}
|
||
.pp-head__pill i { font-size: 0.55rem; }
|
||
|
||
/* ════════════════════════════════════════════════════════════════
|
||
OVERVIEW — dashboard resumo (estilo HomeCards adaptado)
|
||
════════════════════════════════════════════════════════════════ */
|
||
/* Tokens + background da Visão Geral vivem no <main> pra cascatear pra
|
||
tab bar e demais abas, e pra que o background atmosférico cubra todo
|
||
o card (não só a div da Visão Geral). */
|
||
.pp-main {
|
||
--pp-bg: var(--surface-ground);
|
||
--pp-surface: var(--surface-card);
|
||
--pp-border: var(--surface-border);
|
||
--pp-text: var(--text-color);
|
||
--pp-muted: var(--text-color-secondary);
|
||
--pp-dim: color-mix(in srgb, var(--text-color-secondary) 60%, transparent);
|
||
--pp-primary: var(--primary-color, #7c6af7);
|
||
--pp-r: 16px;
|
||
--pp-r-lg: 20px;
|
||
|
||
background: var(--pp-bg);
|
||
color: var(--pp-text);
|
||
}
|
||
|
||
.pp-overview {
|
||
position: relative;
|
||
isolation: isolate;
|
||
font-family: 'DM Sans', system-ui, sans-serif;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* Camada decorativa (spots + linhas) */
|
||
.pp-ov__bg {
|
||
position: absolute;
|
||
inset: 0;
|
||
pointer-events: none;
|
||
z-index: 0;
|
||
overflow: hidden;
|
||
}
|
||
.pp-ov__spot {
|
||
position: absolute;
|
||
border-radius: 50%;
|
||
filter: blur(80px);
|
||
}
|
||
.pp-ov__spot--a {
|
||
width: 480px; height: 480px;
|
||
top: -160px; right: -160px;
|
||
background: radial-gradient(circle, color-mix(in srgb, var(--pp-primary) 14%, transparent), transparent 60%);
|
||
animation: pp-flt 22s ease-in-out infinite alternate;
|
||
}
|
||
.pp-ov__spot--b {
|
||
width: 420px; height: 420px;
|
||
bottom: -120px; left: -120px;
|
||
background: radial-gradient(circle, rgba(74, 222, 128, 0.10), transparent 60%);
|
||
animation: pp-flt 28s ease-in-out infinite alternate-reverse;
|
||
}
|
||
@keyframes pp-flt {
|
||
from { transform: translate(0, 0) scale(1); }
|
||
to { transform: translate(40px, 30px) scale(1.06); }
|
||
}
|
||
.pp-ov__line {
|
||
position: absolute;
|
||
left: 0; right: 0;
|
||
height: 1px;
|
||
background: linear-gradient(90deg, transparent, color-mix(in srgb, var(--pp-text) 6%, transparent), transparent);
|
||
}
|
||
.pp-ov__line--1 { top: 140px; }
|
||
.pp-ov__line--2 { bottom: 220px; }
|
||
|
||
.pp-ov__inner {
|
||
position: relative;
|
||
z-index: 1;
|
||
padding: 12px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 24px;
|
||
}
|
||
|
||
/* ─── KPI CARDS ─── */
|
||
.pp-kpis {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 14px;
|
||
}
|
||
@media (max-width: 1100px) { .pp-kpis { grid-template-columns: repeat(2, 1fr); } }
|
||
@media (max-width: 520px) { .pp-kpis { grid-template-columns: 1fr; } }
|
||
|
||
.pp-kpi {
|
||
--c: var(--pp-primary);
|
||
--c-dim: color-mix(in srgb, var(--pp-primary) 10%, transparent);
|
||
--c-border: color-mix(in srgb, var(--pp-primary) 30%, transparent);
|
||
position: relative;
|
||
overflow: hidden;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
padding: 18px 18px 16px;
|
||
border-radius: var(--pp-r-lg);
|
||
border: 1px solid var(--pp-border);
|
||
background: var(--pp-surface);
|
||
transition: transform 0.22s cubic-bezier(0.22, 1, 0.36, 1),
|
||
border-color 0.22s ease,
|
||
box-shadow 0.22s ease;
|
||
animation: pp-fade 0.5s ease both;
|
||
}
|
||
.pp-kpi:hover {
|
||
transform: translateY(-3px);
|
||
border-color: var(--c-border);
|
||
box-shadow: 0 14px 36px -14px color-mix(in srgb, var(--c) 26%, transparent),
|
||
inset 0 1px 0 color-mix(in srgb, var(--pp-text) 4%, transparent);
|
||
}
|
||
.pp-kpi:hover .pp-kpi__shine { opacity: 1; }
|
||
.pp-kpi:hover .pp-kpi__num { opacity: 0.06; }
|
||
.pp-kpi:hover .pp-kpi__icon { background: color-mix(in srgb, var(--c) 18%, transparent); }
|
||
|
||
.pp-kpi__num {
|
||
position: absolute;
|
||
top: 8px; right: 14px;
|
||
font-size: 3rem;
|
||
font-weight: 900;
|
||
letter-spacing: -0.06em;
|
||
line-height: 1;
|
||
color: var(--pp-text);
|
||
opacity: 0.025;
|
||
pointer-events: none;
|
||
user-select: none;
|
||
transition: opacity 0.22s ease;
|
||
}
|
||
.pp-kpi__shine {
|
||
position: absolute;
|
||
inset: 0;
|
||
border-radius: inherit;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
transition: opacity 0.3s ease;
|
||
background: radial-gradient(ellipse at 25% 0%, color-mix(in srgb, var(--c) 12%, transparent), transparent 55%);
|
||
}
|
||
.pp-kpi__head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
.pp-kpi__icon {
|
||
width: 36px; height: 36px;
|
||
display: grid;
|
||
place-items: center;
|
||
border-radius: 10px;
|
||
font-size: 0.95rem;
|
||
background: var(--c-dim);
|
||
border: 1px solid var(--c-border);
|
||
color: var(--c);
|
||
transition: background 0.22s ease;
|
||
}
|
||
.pp-kpi__tag {
|
||
font-size: 0.6rem;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.12em;
|
||
color: var(--c);
|
||
opacity: 0.75;
|
||
}
|
||
.pp-kpi__body { flex: 1; }
|
||
.pp-kpi__big {
|
||
font-size: 1.85rem;
|
||
font-weight: 700;
|
||
letter-spacing: -0.04em;
|
||
line-height: 1.1;
|
||
color: var(--pp-text);
|
||
}
|
||
.pp-kpi__big--small {
|
||
font-size: 1.05rem;
|
||
line-height: 1.2;
|
||
letter-spacing: -0.02em;
|
||
}
|
||
.pp-kpi__cap {
|
||
margin-top: 6px;
|
||
font-size: 0.74rem;
|
||
color: var(--pp-muted);
|
||
line-height: 1.5;
|
||
}
|
||
.pp-kpi__cap-warn { color: #f87171; font-weight: 600; }
|
||
.pp-kpi__cap-dim { opacity: 0.6; }
|
||
|
||
/* ─── GRID (timeline + side col) ─── */
|
||
.pp-grid {
|
||
display: grid;
|
||
grid-template-columns: 1fr 360px;
|
||
gap: 16px;
|
||
}
|
||
.pp-grid__col {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
@media (max-width: 1100px) {
|
||
.pp-grid { grid-template-columns: 1fr; }
|
||
}
|
||
|
||
/* ─── PANELS ─── */
|
||
.pp-panel {
|
||
border: 1px solid var(--pp-border);
|
||
border-radius: var(--pp-r-lg);
|
||
background: var(--pp-surface);
|
||
overflow: hidden;
|
||
animation: pp-fade 0.5s ease both;
|
||
}
|
||
.pp-panel__head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 13px 18px;
|
||
border-bottom: 1px solid var(--pp-border);
|
||
}
|
||
.pp-panel__title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
font-size: 0.78rem;
|
||
font-weight: 600;
|
||
color: var(--pp-text);
|
||
opacity: 0.85;
|
||
}
|
||
.pp-panel__title .pi { font-size: 0.78rem; opacity: 0.6; }
|
||
.pp-panel__badge {
|
||
font-size: 0.6rem;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
padding: 2px 9px;
|
||
border-radius: 100px;
|
||
background: var(--pp-bg);
|
||
border: 1px solid var(--pp-border);
|
||
color: var(--pp-muted);
|
||
}
|
||
.pp-panel__body {
|
||
padding: 16px 18px;
|
||
}
|
||
|
||
.pp-empty {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
padding: 28px 12px;
|
||
color: var(--pp-dim);
|
||
font-size: 0.82rem;
|
||
text-align: center;
|
||
}
|
||
.pp-empty .pi { font-size: 1.3rem; opacity: 0.5; }
|
||
|
||
/* Variante rica — border tracejada, ícone grande, CTA. Usada quando
|
||
o empty state precisa convidar a uma ação (ex: financeiro vazio). */
|
||
.pp-empty--rich {
|
||
gap: 14px;
|
||
padding: 48px 24px;
|
||
border: 2px dashed color-mix(in srgb, var(--primary-color) 35%, var(--surface-border));
|
||
border-radius: 16px;
|
||
background:
|
||
radial-gradient(ellipse at top, color-mix(in srgb, var(--primary-color) 5%, transparent), transparent 70%),
|
||
var(--surface-card);
|
||
color: var(--text-color-secondary);
|
||
}
|
||
.pp-empty--rich .pp-empty__icon {
|
||
width: 72px;
|
||
height: 72px;
|
||
display: grid;
|
||
place-items: center;
|
||
border-radius: 50%;
|
||
background: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||
border: 1px solid color-mix(in srgb, var(--primary-color) 25%, transparent);
|
||
color: var(--primary-color);
|
||
margin-bottom: 4px;
|
||
}
|
||
.pp-empty--rich .pp-empty__icon .pi {
|
||
font-size: 2rem;
|
||
opacity: 1;
|
||
}
|
||
.pp-empty--rich .pp-empty__title {
|
||
font-size: 1rem;
|
||
font-weight: 700;
|
||
color: var(--text-color);
|
||
letter-spacing: -0.02em;
|
||
}
|
||
.pp-empty--rich .pp-empty__sub {
|
||
font-size: 0.82rem;
|
||
color: var(--text-color-secondary);
|
||
max-width: 340px;
|
||
line-height: 1.5;
|
||
}
|
||
.pp-empty--rich .pp-empty__cta {
|
||
margin-top: 6px;
|
||
}
|
||
|
||
/* ─── TIMELINE ─── */
|
||
.pp-timeline {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
position: relative;
|
||
}
|
||
.pp-timeline::before {
|
||
content: '';
|
||
position: absolute;
|
||
left: 7px;
|
||
top: 6px;
|
||
bottom: 6px;
|
||
width: 1px;
|
||
background: linear-gradient(180deg, var(--pp-border) 0%, transparent 100%);
|
||
}
|
||
.pp-tl {
|
||
position: relative;
|
||
padding: 0 0 14px 28px;
|
||
--tl-c: #94a3b8;
|
||
}
|
||
.pp-tl:last-child { padding-bottom: 0; }
|
||
.pp-tl[data-status*="realiz"] { --tl-c: #4ade80; }
|
||
.pp-tl[data-status*="present"] { --tl-c: #4ade80; }
|
||
.pp-tl[data-status*="falt"] { --tl-c: #f87171; }
|
||
.pp-tl[data-status*="cancel"], .pp-tl[data-status*="remarc"] { --tl-c: #f59e0b; }
|
||
.pp-tl[data-status*="agend"] { --tl-c: #60a5fa; }
|
||
|
||
.pp-tl__dot {
|
||
position: absolute;
|
||
left: 0;
|
||
top: 4px;
|
||
width: 14px;
|
||
height: 14px;
|
||
border-radius: 50%;
|
||
background: var(--pp-surface);
|
||
border: 2px solid var(--tl-c);
|
||
box-shadow: 0 0 0 3px var(--pp-surface), 0 0 12px color-mix(in srgb, var(--tl-c) 50%, transparent);
|
||
}
|
||
.pp-tl__main { display: flex; flex-direction: column; gap: 6px; }
|
||
.pp-tl__top {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.pp-tl__when {
|
||
font-size: 0.78rem;
|
||
font-weight: 600;
|
||
color: var(--pp-text);
|
||
}
|
||
.pp-tl__rel {
|
||
font-size: 0.7rem;
|
||
color: var(--pp-muted);
|
||
opacity: 0.75;
|
||
}
|
||
.pp-tl__row {
|
||
display: flex;
|
||
gap: 6px;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
.pp-tl__tag :deep(.p-tag) {
|
||
font-size: 0.62rem !important;
|
||
padding: 2px 8px !important;
|
||
border-radius: 100px !important;
|
||
}
|
||
.pp-tl__chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: 0.66rem;
|
||
color: var(--pp-muted);
|
||
padding: 2px 8px;
|
||
border-radius: 100px;
|
||
background: var(--pp-bg);
|
||
border: 1px solid var(--pp-border);
|
||
}
|
||
.pp-tl__chip i { font-size: 0.6rem; opacity: 0.7; }
|
||
.pp-tl__chip--dim { opacity: 0.7; }
|
||
.pp-tl__note {
|
||
margin: 0;
|
||
font-size: 0.74rem;
|
||
color: var(--pp-muted);
|
||
line-height: 1.5;
|
||
padding: 6px 10px;
|
||
background: var(--pp-bg);
|
||
border-left: 2px solid var(--tl-c);
|
||
border-radius: 4px;
|
||
}
|
||
|
||
/* ─── MENSAGENS ─── */
|
||
.pp-msgs {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
.pp-msg {
|
||
background: var(--pp-bg);
|
||
border: 1px solid var(--pp-border);
|
||
border-radius: 12px;
|
||
padding: 10px 12px;
|
||
transition: border-color 0.16s ease, transform 0.16s ease;
|
||
}
|
||
.pp-msg:hover {
|
||
transform: translateX(2px);
|
||
border-color: color-mix(in srgb, var(--pp-primary) 30%, var(--pp-border));
|
||
}
|
||
.pp-msg--in { border-left: 3px solid #4ade80; }
|
||
.pp-msg--out { border-left: 3px solid #60a5fa; }
|
||
.pp-msg__meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
font-size: 0.66rem;
|
||
color: var(--pp-muted);
|
||
margin-bottom: 5px;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.04em;
|
||
font-weight: 600;
|
||
}
|
||
.pp-msg__meta i { font-size: 0.65rem; }
|
||
.pp-msg--in .pp-msg__meta i { color: #4ade80; }
|
||
.pp-msg--out .pp-msg__meta i { color: #60a5fa; }
|
||
.pp-msg__sep { opacity: 0.4; }
|
||
.pp-msg__body {
|
||
margin: 0;
|
||
font-size: 0.78rem;
|
||
line-height: 1.5;
|
||
color: var(--pp-text);
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 3;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* ─── NOTAS ─── */
|
||
.pp-notes {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 14px;
|
||
}
|
||
.pp-note {
|
||
padding: 12px 14px;
|
||
background: var(--pp-bg);
|
||
border: 1px solid var(--pp-border);
|
||
border-radius: 12px;
|
||
}
|
||
.pp-note__label {
|
||
margin: 0 0 6px 0;
|
||
font-size: 0.62rem;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.1em;
|
||
font-weight: 700;
|
||
color: var(--pp-muted);
|
||
opacity: 0.65;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
}
|
||
.pp-note__label i { font-size: 0.6rem; }
|
||
.pp-note__text {
|
||
margin: 0;
|
||
font-size: 0.8rem;
|
||
line-height: 1.55;
|
||
color: var(--pp-text);
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
|
||
@keyframes pp-fade {
|
||
from { opacity: 0; transform: translateY(8px); }
|
||
to { opacity: 1; transform: translateY(0); }
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════════════════════
|
||
ABA CONVERSAS — KPIs + CTA + PatientConversationsTab.
|
||
════════════════════════════════════════════════════════════════ */
|
||
.pp-conv {
|
||
padding: 12px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
.pp-conv__kpis {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 14px;
|
||
}
|
||
@media (max-width: 1100px) { .pp-conv__kpis { grid-template-columns: repeat(2, 1fr); } }
|
||
@media (max-width: 520px) { .pp-conv__kpis { grid-template-columns: 1fr; } }
|
||
|
||
.pp-conv__cta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 12px 14px;
|
||
border: 1px dashed color-mix(in srgb, var(--primary-color) 30%, var(--surface-border));
|
||
border-radius: 14px;
|
||
background: color-mix(in srgb, var(--primary-color) 4%, transparent);
|
||
}
|
||
.pp-conv__cta-hint {
|
||
font-size: 0.74rem;
|
||
color: var(--text-color-secondary);
|
||
line-height: 1.4;
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
@media (max-width: 520px) {
|
||
.pp-conv__cta { flex-direction: column; align-items: stretch; }
|
||
.pp-conv__cta-hint { text-align: center; }
|
||
}
|
||
|
||
.pp-conv__list { padding: 14px 16px; animation: pp-fade 0.4s ease both; }
|
||
|
||
/* ════════════════════════════════════════════════════════════════
|
||
ABA DOCUMENTOS — KPIs + DocumentsListPage embedded num panel.
|
||
════════════════════════════════════════════════════════════════ */
|
||
.pp-doc {
|
||
padding: 12px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
.pp-doc__kpis {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 14px;
|
||
}
|
||
@media (max-width: 1100px) { .pp-doc__kpis { grid-template-columns: repeat(2, 1fr); } }
|
||
@media (max-width: 520px) { .pp-doc__kpis { grid-template-columns: 1fr; } }
|
||
|
||
.pp-doc__list { padding: 14px 16px; animation: pp-fade 0.4s ease both; }
|
||
|
||
/* O DocumentsListPage embedded já tem header próprio — só refinamos
|
||
pra integrar com o panel: removemos seu margin-bottom default e
|
||
usamos as cores do tema. */
|
||
.pp-doc__list :deep(.pi-spinner) { color: var(--primary-color); }
|
||
|
||
/* ════════════════════════════════════════════════════════════════
|
||
ABA PRONTUÁRIO EVOLUTIVO — wrapper só pra dar respiro ao empty.
|
||
════════════════════════════════════════════════════════════════ */
|
||
.pp-pron {
|
||
padding: 12px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════════════════════
|
||
ABA AGENDA — KPIs + filtros pill + lista agrupada por mês.
|
||
════════════════════════════════════════════════════════════════ */
|
||
.pp-ag {
|
||
padding: 12px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
.pp-ag__kpis {
|
||
display: grid;
|
||
grid-template-columns: repeat(4, 1fr);
|
||
gap: 14px;
|
||
}
|
||
@media (max-width: 1100px) { .pp-ag__kpis { grid-template-columns: repeat(2, 1fr); } }
|
||
@media (max-width: 520px) { .pp-ag__kpis { grid-template-columns: 1fr; } }
|
||
|
||
/* Filtros pill */
|
||
.pp-ag__filters {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 6px;
|
||
}
|
||
.pp-ag__filter {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 6px 12px;
|
||
border-radius: 100px;
|
||
border: 1px solid var(--surface-border);
|
||
background: var(--surface-card);
|
||
color: var(--text-color-secondary);
|
||
font-size: 0.74rem;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: all 140ms ease;
|
||
}
|
||
.pp-ag__filter i { font-size: 0.7rem; opacity: 0.7; }
|
||
.pp-ag__filter:hover {
|
||
color: var(--text-color);
|
||
border-color: color-mix(in srgb, var(--primary-color) 40%, var(--surface-border));
|
||
}
|
||
.pp-ag__filter.is-active {
|
||
color: var(--primary-color);
|
||
background: color-mix(in srgb, var(--primary-color) 12%, transparent);
|
||
border-color: color-mix(in srgb, var(--primary-color) 35%, transparent);
|
||
font-weight: 600;
|
||
}
|
||
.pp-ag__filter.is-active i { opacity: 1; }
|
||
|
||
.pp-ag__group { animation: pp-fade 0.4s ease both; }
|
||
|
||
.pp-ag__list {
|
||
list-style: none;
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
.pp-ag__item {
|
||
--st-c: #94a3b8;
|
||
display: grid;
|
||
grid-template-columns: 80px 1fr auto;
|
||
gap: 14px;
|
||
padding: 12px 18px;
|
||
border-top: 1px solid var(--surface-border);
|
||
transition: background-color 140ms ease;
|
||
}
|
||
.pp-ag__item:hover {
|
||
background: color-mix(in srgb, var(--text-color) 3%, transparent);
|
||
}
|
||
.pp-ag__item:hover .pp-ag__actions { opacity: 1; }
|
||
.pp-ag__item[data-status*="realiz"] { --st-c: #4ade80; }
|
||
.pp-ag__item[data-status*="present"] { --st-c: #4ade80; }
|
||
.pp-ag__item[data-status*="falt"] { --st-c: #f87171; }
|
||
.pp-ag__item[data-status*="cancel"], .pp-ag__item[data-status*="remarca"] { --st-c: #f59e0b; }
|
||
.pp-ag__item[data-status*="agend"] { --st-c: #60a5fa; }
|
||
|
||
.pp-ag__date {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border: 1px solid var(--surface-border);
|
||
border-left: 3px solid var(--st-c);
|
||
border-radius: 10px;
|
||
padding: 6px 4px;
|
||
background: var(--surface-card);
|
||
min-width: 72px;
|
||
}
|
||
.pp-ag__date-dow {
|
||
font-size: 0.62rem;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
color: var(--text-color-secondary);
|
||
}
|
||
.pp-ag__date-day {
|
||
font-size: 1.4rem;
|
||
font-weight: 700;
|
||
line-height: 1;
|
||
letter-spacing: -0.04em;
|
||
color: var(--text-color);
|
||
margin: 2px 0;
|
||
}
|
||
.pp-ag__date-time {
|
||
font-size: 0.7rem;
|
||
font-variant-numeric: tabular-nums;
|
||
color: var(--text-color-secondary);
|
||
}
|
||
|
||
.pp-ag__main {
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
justify-content: center;
|
||
}
|
||
.pp-ag__top {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.pp-ag__tag :deep(.p-tag) {
|
||
font-size: 0.62rem !important;
|
||
padding: 2px 8px !important;
|
||
border-radius: 100px !important;
|
||
}
|
||
.pp-ag__chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: 0.66rem;
|
||
color: var(--text-color-secondary);
|
||
padding: 2px 8px;
|
||
border-radius: 100px;
|
||
background: var(--surface-ground);
|
||
border: 1px solid var(--surface-border);
|
||
}
|
||
.pp-ag__chip i { font-size: 0.6rem; opacity: 0.75; }
|
||
.pp-ag__chip--dim { opacity: 0.7; }
|
||
.pp-ag__rel {
|
||
margin-left: auto;
|
||
font-size: 0.66rem;
|
||
color: var(--text-color-secondary);
|
||
opacity: 0.75;
|
||
}
|
||
.pp-ag__title {
|
||
font-size: 0.82rem;
|
||
font-weight: 600;
|
||
color: var(--text-color);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.pp-ag__note {
|
||
margin: 0;
|
||
font-size: 0.74rem;
|
||
color: var(--text-color-secondary);
|
||
line-height: 1.5;
|
||
padding: 6px 10px;
|
||
background: var(--surface-ground);
|
||
border-left: 2px solid var(--st-c);
|
||
border-radius: 4px;
|
||
display: -webkit-box;
|
||
-webkit-line-clamp: 2;
|
||
-webkit-box-orient: vertical;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.pp-ag__actions {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
opacity: 0;
|
||
transition: opacity 140ms ease;
|
||
align-self: center;
|
||
}
|
||
.pp-ag__act {
|
||
width: 32px;
|
||
height: 32px;
|
||
display: grid;
|
||
place-items: center;
|
||
border: 1px solid var(--surface-border);
|
||
background: var(--surface-card);
|
||
border-radius: 8px;
|
||
color: var(--text-color-secondary);
|
||
cursor: pointer;
|
||
transition: all 140ms ease;
|
||
font-size: 0.78rem;
|
||
}
|
||
.pp-ag__act:hover:not(:disabled) {
|
||
transform: translateX(-1px);
|
||
}
|
||
.pp-ag__act:disabled {
|
||
opacity: 0.4;
|
||
cursor: not-allowed;
|
||
}
|
||
.pp-ag__act--ok:hover:not(:disabled) { color: rgb(16, 185, 129); border-color: rgba(16, 185, 129, 0.5); background: rgba(16, 185, 129, 0.10); }
|
||
.pp-ag__act--warn:hover:not(:disabled) { color: rgb(245, 158, 11); border-color: rgba(245, 158, 11, 0.5); background: rgba(245, 158, 11, 0.10); }
|
||
.pp-ag__act--danger:hover:not(:disabled) { color: rgb(239, 68, 68); border-color: rgba(239, 68, 68, 0.5); background: rgba(239, 68, 68, 0.10); }
|
||
|
||
@media (max-width: 760px) {
|
||
.pp-ag__item { grid-template-columns: 64px 1fr; padding: 10px 14px; }
|
||
.pp-ag__actions { grid-column: 1 / -1; flex-direction: row; opacity: 1; justify-self: end; }
|
||
.pp-ag__rel { margin-left: 0; }
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════════════════════
|
||
ABA FINANCEIRO — KPIs no topo + tabela de lançamentos.
|
||
════════════════════════════════════════════════════════════════ */
|
||
.pp-fin {
|
||
padding: 12px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16px;
|
||
}
|
||
.pp-fin__kpis {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 14px;
|
||
}
|
||
@media (max-width: 760px) { .pp-fin__kpis { grid-template-columns: 1fr; } }
|
||
|
||
.pp-fin__list { animation: pp-fade 0.4s ease both; }
|
||
|
||
.pp-fin__table {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.pp-fin__row {
|
||
display: grid;
|
||
grid-template-columns: 130px 1fr 110px 110px 110px;
|
||
align-items: center;
|
||
gap: 12px;
|
||
padding: 10px 18px;
|
||
border-top: 1px solid var(--surface-border);
|
||
transition: background-color 140ms ease;
|
||
font-size: 0.78rem;
|
||
}
|
||
.pp-fin__row:not(.pp-fin__row--head):hover {
|
||
background: color-mix(in srgb, var(--text-color) 3%, transparent);
|
||
}
|
||
.pp-fin__row--head {
|
||
border-top: none;
|
||
padding: 10px 18px;
|
||
background: var(--surface-ground);
|
||
font-size: 0.6rem;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.08em;
|
||
color: var(--text-color-secondary);
|
||
opacity: 0.7;
|
||
}
|
||
@media (max-width: 760px) {
|
||
.pp-fin__row {
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: 6px 12px;
|
||
}
|
||
.pp-fin__row--head { display: none; }
|
||
.pp-fin__date { grid-column: 1 / 2; order: 1; }
|
||
.pp-fin__amount { grid-column: 2 / 3; order: 2; text-align: right; }
|
||
.pp-fin__desc { grid-column: 1 / -1; order: 3; }
|
||
.pp-fin__method { grid-column: 1 / 2; order: 4; }
|
||
.pp-fin__status-cell { grid-column: 2 / 3; order: 5; justify-self: end; }
|
||
}
|
||
|
||
.pp-fin__col-amount { text-align: right; }
|
||
.pp-fin__date {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 1px;
|
||
}
|
||
.pp-fin__date-main {
|
||
font-weight: 600;
|
||
color: var(--text-color);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.pp-fin__date-rel {
|
||
font-size: 0.66rem;
|
||
color: var(--text-color-secondary);
|
||
opacity: 0.75;
|
||
}
|
||
.pp-fin__desc {
|
||
color: var(--text-color);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.pp-fin__method {
|
||
color: var(--text-color-secondary);
|
||
font-size: 0.74rem;
|
||
}
|
||
.pp-fin__amount {
|
||
font-weight: 700;
|
||
color: var(--text-color);
|
||
text-align: right;
|
||
font-variant-numeric: tabular-nums;
|
||
letter-spacing: -0.01em;
|
||
}
|
||
.pp-fin__row[data-status="vencido"] .pp-fin__amount { color: rgb(239, 68, 68); }
|
||
.pp-fin__row[data-status="pago"] .pp-fin__amount { color: rgb(16, 185, 129); }
|
||
|
||
.pp-fin__status {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
padding: 3px 9px;
|
||
border-radius: 100px;
|
||
font-size: 0.66rem;
|
||
font-weight: 600;
|
||
letter-spacing: 0.02em;
|
||
border: 1px solid;
|
||
--st-c: #94a3b8;
|
||
--st-bg: rgba(148, 163, 184, 0.10);
|
||
--st-border: rgba(148, 163, 184, 0.30);
|
||
color: var(--st-c);
|
||
background: var(--st-bg);
|
||
border-color: var(--st-border);
|
||
}
|
||
.pp-fin__status[data-status="pago"] {
|
||
--st-c: rgb(16, 185, 129);
|
||
--st-bg: rgba(16, 185, 129, 0.10);
|
||
--st-border: rgba(16, 185, 129, 0.30);
|
||
}
|
||
.pp-fin__status[data-status="pendente"] {
|
||
--st-c: var(--primary-color);
|
||
--st-bg: color-mix(in srgb, var(--primary-color) 10%, transparent);
|
||
--st-border: color-mix(in srgb, var(--primary-color) 30%, transparent);
|
||
}
|
||
.pp-fin__status[data-status="vencido"] {
|
||
--st-c: rgb(239, 68, 68);
|
||
--st-bg: rgba(239, 68, 68, 0.10);
|
||
--st-border: rgba(239, 68, 68, 0.30);
|
||
}
|
||
.pp-fin__status-dot {
|
||
width: 6px;
|
||
height: 6px;
|
||
border-radius: 50%;
|
||
background: var(--st-c);
|
||
box-shadow: 0 0 6px var(--st-c);
|
||
}
|
||
|
||
/* ════════════════════════════════════════════════════════════════
|
||
AÇÕES da sidebar — espelha .evento-actions do MelissaEventoPanel.
|
||
Status da próxima sessão (Realizada/Falta/Reagendar/Cancelar)
|
||
+ atalhos (Sessões/Conversar/Editar).
|
||
════════════════════════════════════════════════════════════════ */
|
||
.pp-actions {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 12px;
|
||
padding-bottom: 14px;
|
||
margin-bottom: 14px;
|
||
border-bottom: 1px solid var(--surface-border);
|
||
}
|
||
.pp-actions__section {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
.pp-actions__label {
|
||
font-size: 0.62rem;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.06em;
|
||
color: var(--text-color-secondary);
|
||
opacity: 0.7;
|
||
padding-left: 2px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.pp-actions__hint {
|
||
font-size: 0.6rem;
|
||
font-weight: 500;
|
||
text-transform: none;
|
||
letter-spacing: 0;
|
||
opacity: 0.7;
|
||
font-style: italic;
|
||
}
|
||
.pp-actions__group {
|
||
display: flex;
|
||
gap: 4px;
|
||
background: var(--surface-ground);
|
||
border: 1px solid var(--surface-border);
|
||
border-radius: 12px;
|
||
padding: 4px;
|
||
}
|
||
.pp-act {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 4px;
|
||
padding: 8px 4px;
|
||
background: transparent;
|
||
border: none;
|
||
color: var(--text-color);
|
||
border-radius: 9px;
|
||
cursor: pointer;
|
||
font-size: 1rem;
|
||
font-family: inherit;
|
||
transition: background-color 140ms ease, color 140ms ease, transform 140ms ease, box-shadow 140ms ease;
|
||
}
|
||
.pp-act__label {
|
||
font-size: 0.68rem;
|
||
line-height: 1.1;
|
||
font-weight: 500;
|
||
letter-spacing: 0.01em;
|
||
white-space: nowrap;
|
||
}
|
||
.pp-act:hover:not(:disabled) {
|
||
background: color-mix(in srgb, var(--text-color) 6%, transparent);
|
||
transform: translateY(-1px);
|
||
}
|
||
.pp-act:focus-visible {
|
||
outline: 2px solid var(--primary-color);
|
||
outline-offset: 2px;
|
||
}
|
||
.pp-act:disabled {
|
||
opacity: 0.4;
|
||
cursor: not-allowed;
|
||
}
|
||
.pp-act--ok:hover:not(:disabled) {
|
||
color: rgb(16, 185, 129);
|
||
background: rgba(16, 185, 129, 0.12);
|
||
}
|
||
.pp-act--warn:hover:not(:disabled) {
|
||
color: rgb(245, 158, 11);
|
||
background: rgba(245, 158, 11, 0.12);
|
||
}
|
||
.pp-act--danger:hover:not(:disabled) {
|
||
color: rgb(239, 68, 68);
|
||
background: rgba(239, 68, 68, 0.12);
|
||
}
|
||
/* Estado .is-current — destaca o status atual da próxima sessão */
|
||
.pp-act.is-current {
|
||
background: color-mix(in srgb, var(--text-color) 10%, transparent);
|
||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-color) 20%, transparent);
|
||
}
|
||
.pp-act--ok.is-current {
|
||
color: rgb(16, 185, 129);
|
||
background: rgba(16, 185, 129, 0.16);
|
||
box-shadow: inset 0 0 0 1px rgba(16, 185, 129, 0.5);
|
||
}
|
||
.pp-act--warn.is-current {
|
||
color: rgb(245, 158, 11);
|
||
background: rgba(245, 158, 11, 0.16);
|
||
box-shadow: inset 0 0 0 1px rgba(245, 158, 11, 0.5);
|
||
}
|
||
.pp-act--danger.is-current {
|
||
color: rgb(239, 68, 68);
|
||
background: rgba(239, 68, 68, 0.16);
|
||
box-shadow: inset 0 0 0 1px rgba(239, 68, 68, 0.5);
|
||
}
|
||
</style>
|