9e76e4e6ea
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>
400 lines
13 KiB
JavaScript
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 ? `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' };
|
|
}
|