/* |-------------------------------------------------------------------------- | Agência PSI |-------------------------------------------------------------------------- | Arquivo: src/features/patients/utils/patientFormatters.js | | Helpers de formatacao compartilhaveis entre PatientProntuario.vue (legacy) | e MelissaPaciente.vue (Melissa nativo). Extraidos do PatientProntuario | pra eliminar duplicacao quando MelissaPaciente substituir o legacy | na Fase 8. |-------------------------------------------------------------------------- */ /** * Tenta varios formatos de data: ISO, DD/MM/YYYY, YYYY-MM-DD, etc. */ export function parseDateLoose(v) { if (!v) return null; if (v instanceof Date) return Number.isNaN(v.getTime()) ? null : v; const s = String(v).trim(); if (!s) return null; // DD/MM/YYYY 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 Number.isNaN(d.getTime()) ? null : d; } // YYYY-MM-DD m = s.match(/^(\d{4})-(\d{2})-(\d{2})$/); if (m) { const d = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])); return Number.isNaN(d.getTime()) ? null : d; } const d = new Date(s); return Number.isNaN(d.getTime()) ? null : d; } export function dash(v) { const s = String(v ?? '').trim(); 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) : '—'; return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`; } /** * Hora curta HH:MM (24h pt-br). */ export 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')}`; } /** * Dia da semana abreviado pt-br (seg/ter/qua...). */ export 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('.', ''); } export function fmtDateTimeBR(iso) { if (!iso) return '—'; const d = new Date(iso); if (Number.isNaN(d.getTime())) 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}`; } /** * Bytes -> string legivel (B/KB/MB/GB). */ export 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`; } /** * Map de tipos de documento clinico pra label pt-br. */ export const DOC_TYPE_LABEL = { atestado: 'Atestado', receita: 'Receita', laudo: 'Laudo', encaminhamento: 'Encaminhamento', termo: 'Termo', termo_assinado: 'Termo assinado', relatorio: 'Relatório', declaracao: 'Declaração', outro: 'Outro' }; /** * Map de dia da semana (0=Domingo) -> label pt-br. */ export const WEEKDAY_LABEL = ['Domingo', 'Segunda', 'Terça', 'Quarta', 'Quinta', 'Sexta', 'Sábado']; export const WEEKDAY_LABEL_SHORT = ['Dom', 'Seg', 'Ter', 'Qua', 'Qui', 'Sex', 'Sáb']; /** * Label legivel da regra de recorrencia. * Ex: "Toda segunda às 14:00", "A cada 2 semanas, terça às 09:00", * "Quartas e sextas às 16:00", "Mensal no dia 15". */ export function fmtRecurrenceLabel(rule) { if (!rule) return '—'; const time = String(rule.start_time || '').slice(0, 5); const interval = Number(rule.interval) || 1; if (rule.type === 'weekly' || (rule.type === 'biweekly' && interval === 1)) { const dow = (rule.weekdays || [])[0]; if (dow == null) return time ? `Semanal às ${time}` : 'Semanal'; const dayLbl = WEEKDAY_LABEL[dow] || '?'; if (rule.type === 'biweekly') { return time ? `Quinzenal · ${dayLbl} às ${time}` : `Quinzenal · ${dayLbl}`; } return time ? `Toda ${dayLbl.toLowerCase()} às ${time}` : `Toda ${dayLbl.toLowerCase()}`; } if (rule.type === 'custom_weekdays') { const dows = (rule.weekdays || []).map((d) => WEEKDAY_LABEL_SHORT[d]).filter(Boolean); const dayList = dows.length ? dows.join(', ') : '?'; return time ? `${dayList} às ${time}` : dayList; } if (rule.type === 'monthly') { return time ? `Mensal às ${time}` : 'Mensal'; } if (rule.type === 'yearly') { return time ? `Anual às ${time}` : 'Anual'; } return rule.type || 'Recorrência'; } /** * Label pro fim da regra: "Sem data de fim", "Até DD/MM/YYYY", "N sessões no total". */ export function fmtRecurrenceFim(rule) { if (!rule) return ''; if (rule.end_date) return `Até ${fmtDateBR(rule.end_date)}`; if (rule.max_occurrences) { const n = Number(rule.max_occurrences); return `${n} ${n === 1 ? 'sessão' : 'sessões'} no total`; } return 'Sem data de fim'; } /** * Channel label pra conversa: whatsapp -> WhatsApp, sms -> SMS, email -> E-mail. */ export 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 || ''; } export function fmtCurrency(v) { if (v === null || v === undefined || v === '') return '—'; return `R$ ${Number(v).toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; } export 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 em pt-BR. * Retorna: "agora", "ha 5 min", "ha 2 h", "ontem", "ha 3 dias", * "em 2 dias", "em 3 sem", ou data absoluta para >= 5 semanas. */ export function fmtRelative(iso) { if (!iso) return '—'; const target = new Date(iso).getTime(); if (Number.isNaN(target)) return '—'; const diff = target - Date.now(); 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); } /** * Idade em anos com base em data de nascimento. */ export 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; } /** * Status visual de uma sessao da agenda. */ export const STATUS_LABEL = { agendado: 'Agendado', realizado: 'Realizado', realizada: 'Realizado', faltou: 'Faltou', falta: 'Faltou', cancelado: 'Cancelado', cancelada: 'Cancelado', remarcado: 'Remarcado', bloqueado: 'Bloqueado' }; /** * Determina o status financeiro de um lancamento: * - "pago": paid_at preenchido * - "vencido": due_date < hoje E paid_at vazio * - "pendente": demais casos com paid_at vazio */ export 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'; } export const RECORD_STATUS_LABEL = { pago: 'Pago', pendente: 'Pendente', vencido: 'Vencido' }; /** * Mapeia variantes de payment_method pra label legivel. */ export 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' || s === 'ted' || s === 'doc') return 'Transferência'; if (s === 'convenio' || s === 'convênio') return 'Convênio'; return v; } export const STATUS_SEVERITY = { agendado: 'info', realizado: 'success', realizada: 'success', faltou: 'danger', falta: 'danger', cancelado: 'warn', cancelada: 'warn', remarcado: 'secondary', bloqueado: 'secondary' }; /** * Tag styling com contraste auto (texto preto/branco baseado em luminancia). */ 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) => Number.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]; } export function bestTextColor(bg) { const rgb = hexToRgb(normalizeHexColor(bg)); if (!rgb) return '#0f172a'; return relativeLuminance(rgb) < 0.45 ? '#ffffff' : '#0f172a'; } export function tagStyle(t) { const bg = normalizeHexColor(t?.color || t?.cor); if (!bg) return {}; return { background: bg, color: bestTextColor(bg), borderColor: 'transparent' }; }