diff --git a/Obsidian/Brain/log.md b/Obsidian/Brain/log.md index 0164ec5..a9b3eca 100644 --- a/Obsidian/Brain/log.md +++ b/Obsidian/Brain/log.md @@ -50,6 +50,53 @@ Touched: none ## [2026-05-08 00:00] session | Melissa cfg-* nativas + temas + cronometro DB Touched: none +## [2026-05-08 14:30] session | MelissaPaciente Fase 3 — Tab Perfil (6 sections stacked) +Touched: none +Detalhes: Substituiu o placeholder da aba Perfil por 6 sections stacked +com anchors no MelissaPaciente. Diferente do PatientProntuario legacy que +usava PrimeVue Accordion (1 painel aberto por vez), o Melissa nativo +mostra todos os 6 cards stacked com scroll suave do sidebar sub-nav pra +cada anchor. Mais legivel em desktop, mais rapido pra escanear. + +EXTENSAO de patientFormatters.js: +5 formatters +- pickField (ja existia computed local; agora helper compartilhavel) +- onlyDigits, fmtCPF (000.000.000-00), fmtRG (passthrough), fmtPhoneMobile + ((XX) 9XXXX-XXXX), fmtGender (Masculino/Feminino/Nao-binario/Outro), + fmtMarital (Solteiro/Casado/Divorciado/Viuvo/Uniao estavel). + +MELISSAPACIENTE.VUE — script: +- 30+ field computeds usando pickField (cobre snake_case + camelCase do + schema): birthValue, telefone/Alternativo, email/Alternativo, genero, + estadoCivil, naturalidade, ondeNosConheceu, encaminhadoPor, observacoes, + notasInternas + 8 campos de endereco (cep/pais/cidade/estado/endereco/ + numero/bairro/complemento) + 5 dados adicionais (escolaridade/profissao/ + nomeParente/grauParentesco/telefoneParente) + 4 responsavel. +- groupNames/groupLabel/groupCountLabel pra Origem. +- scrollToProfileSection(key) que liga sidebar sub-nav -> nextTick -> + scrollIntoView do anchor #mpa-perfil-XXX. Em mobile fecha o drawer. + +MELISSAPACIENTE.VUE — template Tab Perfil: +- 1. Informacoes Pessoais: 2-col (Dados de cadastro: nome/data nasc com + idade/genero/estado civil/CPF/RG/naturalidade) + (Contato: tel/tel-alt + com tel: links + e-mail principal/alt com mailto: + Origem: grupos/tags + chips/onde nos conheceu/encaminhado por). Observacoes full-width quando + preenchido. +- 2. Endereco: grid 2-col com 8 fields (CEP/pais/cidade/estado/endereco/ + numero/bairro/complemento). +- 3. Dados Adicionais: grid 2-col com escolaridade/profissao/parente/grau/ + tel parente. +- 4. Responsavel: 1-col com nome/CPF/tel + observacao block textual. +- 5. Anotacoes Internas: card com hint lock + textblock min-height. +- 6. Sessoes: lista compacta scrollable (max-height 360px) com titulo/ + data/duracao/modalidade chips + tag status. + +CSS: ~250L novos pros componentes (mpa-fields/field-row/field-grid-2/ +field-block/sess/sess-list). Pattern visual Melissa: cards com label +uppercase, separadores horizontais sutis, links com cor primary, monospace +pra CPF/RG/CEP. + +ESLint: 0 errors da minha mudanca. + ## [2026-05-08 13:00] session | MelissaPaciente Fase 2 — Tab Visao Geral completa Touched: none Detalhes: Reescreveu a aba Visao Geral do MelissaPaciente substituindo o diff --git a/src/features/patients/utils/patientFormatters.js b/src/features/patients/utils/patientFormatters.js index 219285b..2320756 100644 --- a/src/features/patients/utils/patientFormatters.js +++ b/src/features/patients/utils/patientFormatters.js @@ -40,6 +40,82 @@ export function dash(v) { return s || '—'; } +/** + * Pega a primeira chave nao vazia de um objeto (snake_case ou camelCase). + * Usado pra resolver discrepancias de schema (ex: 'data_nascimento' vs 'birth_date'). + */ +export function pickField(obj, keys = []) { + for (const k of keys) { + const v = obj?.[k]; + if (v !== null && v !== undefined && String(v).trim()) return v; + } + return null; +} + +export function onlyDigits(v) { + return String(v ?? '').replace(/\D/g, ''); +} + +/** + * Formata CPF: 00000000000 -> 000.000.000-00 + */ +export 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)}`; +} + +/** + * Formata RG (genericamente — varia por estado): + * 00.000.000-0 / 0000000000 → mantem digitos com pontos a cada 3 a partir da direita. + */ +export function fmtRG(v) { + const s = String(v ?? '').trim(); + if (!s) return '—'; + return s; +} + +/** + * Formata telefone celular pt-br: (XX) 9XXXX-XXXX ou (XX) XXXX-XXXX. + */ +export 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; +} + +/** + * Mapeia variantes de genero pra label legivel. + */ +export 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; +} + +/** + * Mapeia variantes de estado civil pra label pt-br. + */ +export 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; +} + export function fmtDateBR(v) { const d = parseDateLoose(v); if (!d) return v ? dash(v) : '—'; diff --git a/src/layout/melissa/MelissaPaciente.vue b/src/layout/melissa/MelissaPaciente.vue index 34b529c..0bc2417 100644 --- a/src/layout/melissa/MelissaPaciente.vue +++ b/src/layout/melissa/MelissaPaciente.vue @@ -18,7 +18,7 @@ * * Prefixo CSS: .mpa-* (Melissa PAciente). */ -import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'; +import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'; import { useToast } from 'primevue/usetoast'; import MelissaConfigList from './MelissaConfigList.vue'; import { usePatientDetail } from '@/features/patients/composables/usePatientDetail'; @@ -27,11 +27,17 @@ import { usePatientFinancial } from '@/features/patients/composables/usePatientF import { usePatientMessages } from '@/features/patients/composables/usePatientMessages'; import { usePatientDocuments } from '@/features/patients/composables/usePatientDocuments'; import { + pickField, calcAge, fmtRelative, fmtDateBR, fmtDateTimeBR, fmtCurrency, + fmtCPF, + fmtRG, + fmtGender, + fmtMarital, + fmtPhoneMobile, sessionDuration, STATUS_LABEL, STATUS_SEVERITY, @@ -95,6 +101,16 @@ const PROFILE_SECTIONS = [ { key: 'sess', label: 'Sessões', icon: 'pi pi-calendar' } ]; const activeProfileSection = ref('pessoais'); +function scrollToProfileSection(key) { + activeProfileSection.value = key; + if (isMobile.value) drawerOpen.value = false; + nextTick(() => { + const el = document.getElementById(`mpa-perfil-${key}`); + if (el && typeof el.scrollIntoView === 'function') { + el.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }); +} // ── Helpers de exibicao ──────────────────────────────────── function dash(v) { @@ -127,16 +143,47 @@ function tagStyle(t) { return tagStyleHelper(t); } -// ── Notas + observacoes (campos opcionais do paciente) ───── -function pickField(obj, keys = []) { - for (const k of keys) { - const v = obj?.[k]; - if (v !== null && v !== undefined && String(v).trim()) return v; - } - return null; -} -const observacoes = computed(() => pickField(patientData.value, ['observacoes', 'notes_short'])); -const notasInternas = computed(() => pickField(patientData.value, ['notas_internas', 'notes'])); +// ── Field computeds (cobre snake_case e camelCase do schema) ─ +const birthValue = computed(() => pickField(patientData.value, ['data_nascimento', 'birth_date'])); +const telefone = computed(() => pickField(patientData.value, ['telefone', 'phone'])); +const telefoneAlternativo = computed(() => pickField(patientData.value, ['telefone_alternativo', 'phone_alt', 'phoneAlt'])); +const emailPrincipal = computed(() => pickField(patientData.value, ['email_principal', 'email'])); +const emailAlternativo = computed(() => pickField(patientData.value, ['email_alternativo', 'email_alt', 'emailAlt'])); +const genero = computed(() => pickField(patientData.value, ['genero', 'gender'])); +const estadoCivil = computed(() => pickField(patientData.value, ['estado_civil', 'marital_status'])); +const naturalidade = computed(() => pickField(patientData.value, ['naturalidade', 'birthplace', 'place_of_birth'])); +const ondeNosConheceu = computed(() => pickField(patientData.value, ['onde_nos_conheceu', 'lead_source'])); +const encaminhadoPor = computed(() => pickField(patientData.value, ['encaminhado_por', 'referred_by'])); +const observacoes = computed(() => pickField(patientData.value, ['observacoes', 'notes_short'])); +const notasInternas = computed(() => pickField(patientData.value, ['notas_internas', 'notes'])); + +// Endereço +const cep = computed(() => pickField(patientData.value, ['cep', 'postal_code'])); +const pais = computed(() => pickField(patientData.value, ['pais', 'country'])); +const cidade = computed(() => pickField(patientData.value, ['cidade', 'city'])); +const estado = computed(() => pickField(patientData.value, ['estado', 'state'])); +const endereco = computed(() => pickField(patientData.value, ['endereco', 'address_line'])); +const numero = computed(() => pickField(patientData.value, ['numero', 'address_number'])); +const bairro = computed(() => pickField(patientData.value, ['bairro', 'neighborhood'])); +const complemento = computed(() => pickField(patientData.value, ['complemento', 'address_complement'])); + +// Dados Adicionais +const escolaridade = computed(() => pickField(patientData.value, ['escolaridade', 'education', 'education_level'])); +const profissao = computed(() => pickField(patientData.value, ['profissao', 'profession'])); +const nomeParente = computed(() => pickField(patientData.value, ['nome_parente', 'relative_name'])); +const grauParentesco = computed(() => pickField(patientData.value, ['grau_parentesco', 'relative_relation'])); +const telefoneParente = computed(() => pickField(patientData.value, ['telefone_parente', 'relative_phone'])); + +// Responsável (pra menores) +const nomeResponsavel = computed(() => pickField(patientData.value, ['nome_responsavel', 'guardian_name'])); +const cpfResponsavel = computed(() => pickField(patientData.value, ['cpf_responsavel', 'guardian_cpf'])); +const telefoneResponsavel = computed(() => pickField(patientData.value, ['telefone_responsavel', 'guardian_phone'])); +const observacaoResponsavel = computed(() => pickField(patientData.value, ['observacao_responsavel', 'guardian_note'])); + +// Grupos como string label (Origem na Informacoes Pessoais) +const groupNames = computed(() => detail.groups.value.map((g) => g?.name).filter(Boolean)); +const groupLabel = computed(() => groupNames.value.length ? groupNames.value.join(', ') : '—'); +const groupCountLabel = computed(() => groupNames.value.length <= 1 ? 'Grupo' : 'Grupos'); // ── KPIs Visao Geral (Fase 2) ────────────────────────────── const kpiSessoes = computed(() => sessionsHook.totalSessoes.value); @@ -349,7 +396,7 @@ void toast; type="button" class="mpa-tab-btn mpa-tab-btn--sub" :class="{ 'is-active': activeProfileSection === s.key }" - @click="activeProfileSection = s.key" + @click="scrollToProfileSection(s.key)" > {{ s.label }} @@ -625,23 +672,323 @@ void toast; - +
- Em desenvolvimento — Fase 3. Seção atual:
- {{ activeProfileSection }}.
-
Dados de cadastro
+Contato
+ + + + +Origem
+Observações
+{{ observacoes }}
+Localização
+Formação & família
+Dados do responsável
+Observação
+{{ observacaoResponsavel }}
++ {{ notasInternas ? notasInternas : '—' }} +
++ {{ s.titulo_custom || s.titulo || (s.tipo ? s.tipo : 'Sessão') }} +
+ +{{ s.observacoes }}
+