Files
agenciapsilmno/src/features/patients/utils/patientFormatters.js
T
Leonardo 9e76e4e6ea MelissaPaciente: bloco "Recorrencias do paciente" na Tab Agenda
User aprovou a ideia. Adiciona contexto "este paciente tem sessao toda
segunda 14h" direto no prontuario, evitando duplicacao de regras e
deixando claro o estado da serie.

NOVO src/features/patients/composables/usePatientRecurrences.js (~110L)
- load(patientId): SELECT recurrence_rules WHERE patient_id (DESC start_date)
- cancel(ruleId) / reactivate(ruleId): UPDATE status + auto-reload
- Computeds derivados: ativas, canceladas, totalAtivas, totalCanceladas
- busy flag pra disable de buttons

EXTENSAO src/features/patients/utils/patientFormatters.js
- WEEKDAY_LABEL + WEEKDAY_LABEL_SHORT (arrays 0=Domingo..6=Sabado)
- fmtRecurrenceLabel(rule): "Toda segunda às 14:00", "Quinzenal · Terça
  às 09:00", "Qua, Sex às 16:00" (custom_weekdays), "Mensal às 14:00",
  "Anual" — cobre todos os types do useRecurrence.
- fmtRecurrenceFim(rule): "Sem data de fim" / "Até DD/MM/YYYY" /
  "N sessões no total"

MELISSAPACIENTE.VUE
- Composable + handlers (onCancelRecurrence, onReactivateRecurrence) com
  toast feedback.
- recorrenciasShowCanc ref + recorrenciasVisiveis computed (toggle "ver
  canceladas").
- loadAll inclui recorrenciasHook.load.
- salvarSessao no caminho recorrente recarrega sessions+recorrencias em
  Promise.all (regra recem-criada aparece na lista imediatamente).
- 5o KPI na Tab Agenda: "Recorrencias" com count ativas + cap dinamica
  (cor #a855f7 quando > 0, cinza quando 0).
- Bloco <section class="mpa-panel"> entre KPIs e filter chips listando
  rules ativas (default) ou todas (toggle "Ver canceladas" no header,
  so aparece quando ha canceladas):
  - Icon roxo .mpa-recur-item__icon
  - Top: label + Tag status (verde Ativa / amarelo Cancelada)
  - Meta: duracao + modalidade + fim + "desde DATE"
  - Obs (quando preenchido): block textual
  - Actions: pi-ban (cancelar) ou pi-undo (reativar) com tooltip
- border-left adaptativa (#a855f7 ativo / cinza cancelado) + opacity 0.7
  pros cancelados.
- Mobile: stack icon+main em 2-col 2-row; actions full-width abaixo.

CSS: ~120L novos. Padrao Melissa: status pills, icon roxo distintivo
(diferente das sessoes que usam cinza), border-left por status.

ESLint: 0 errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 11:37:45 -03:00

400 lines
13 KiB
JavaScript

/*
|--------------------------------------------------------------------------
| 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 ? `${min} min` : `em ${min} min`;
const h = Math.round(min / 60);
if (h < 24) return past ? `${h} h` : `em ${h} h`;
const d = Math.round(h / 24);
if (d === 1) return past ? 'ontem' : 'amanhã';
if (d < 7) return past ? `${d} dias` : `em ${d} dias`;
const w = Math.round(d / 7);
if (w < 5) return past ? `${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' };
}