/* |-------------------------------------------------------------------------- | Agência PSI |-------------------------------------------------------------------------- | Arquivo: src/features/agenda/composables/agendaEventHelpers.js | Data: 2026-05-04 | | Helpers PUROS extraídos do AgendaEventDialog.vue (sub-sessão 1A do | refator A66 — vide HANDOFF.md). Sem dependência de Vue ou de refs | reativos. Recebem entrada → retornam saída. Testáveis isoladamente. | | O módulo cobre 4 categorias: | 1. Formatters de data/hora/duração/moeda (fmt*) | 2. Parsers/conversores (hhmmToMin, minToHHMM, isoToHHMM, ...) | 3. Predicados (isPast, isNativeSession, isForaDoPlano) | 4. Cálculos (calcFinalPrice, calcMinutes, addMinutesDate) | 5. Mapeamentos de status (labelStatusSessao, statusSeverity, | statusExtraClass) — fixos por design. | | Próximas etapas (1B/1C) extraem state + computeds + handlers reativos | num composable factory que dependerá deste módulo. |-------------------------------------------------------------------------- */ // ──────────────────────────────────────────────────────────────────────── // Identidade / texto // ──────────────────────────────────────────────────────────────────────── /** * Iniciais do paciente pra avatar fallback (ex. "Ana Souza" → "AS"). * Trata "" / null retornando '?'. */ export function patientInitials(nome) { const parts = String(nome || '') .trim() .split(/\s+/) .filter(Boolean); if (!parts.length) return '?'; if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); } // ──────────────────────────────────────────────────────────────────────── // Formatters — moeda, hora, data, duração // ──────────────────────────────────────────────────────────────────────── /** Formata número como BRL (R$ 1.234,56). null/undefined → '—'. */ export function fmtBRL(v) { if (v == null) return '—'; return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); } /** * Hora compacta pra labels de jornada/duração (ex. "9h", "14h30"). * Suporta entrada "HH:MM" ou "HH:MM:SS". */ export function fmtJornadaHora(hhmm) { const [h, m] = String(hhmm || '00:00') .slice(0, 5) .split(':') .map(Number); return m > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${h}h`; } /** Data BR curta (15 mai 2026). Aceita Date ou string ISO. */ export function fmtDateBR(d) { const dt = d instanceof Date ? d : new Date(d); return dt.toLocaleDateString('pt-BR', { day: '2-digit', month: 'short', year: 'numeric' }); } /** Data BR longa (sex, 15 mai). Aceita Date ou string ISO. */ export function fmtDateBRLong(d) { const dt = d instanceof Date ? d : new Date(d); return dt.toLocaleDateString('pt-BR', { weekday: 'short', day: '2-digit', month: 'short' }); } /** Hora HH:MM. null → '—'. */ export function fmtTime(d) { if (!d) return '—'; return new Date(d).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }); } /** Duração legível (90 → "1h 30min", 45 → "45min", 0/null → "—"). */ export function fmtDuracao(min) { const m = Number(min || 0); if (!m) return '—'; const h = Math.floor(m / 60); const r = m % 60; if (h && r) return `${h}h ${r}min`; if (h) return `${h}h`; return `${r}min`; } /** Hora da série truncada em HH:MM (descarta segundos do TIME). */ export function fmtSerieHora(hora) { if (!hora) return '—'; return String(hora).slice(0, 5); } /** Dia da semana 0-6 → nome lowercase ('domingo'..'sábado'). */ export function nomeDiaSemana(dow) { const nomes = ['domingo', 'segunda', 'terça', 'quarta', 'quinta', 'sexta', 'sábado']; return nomes[Number(dow ?? 0)] ?? '—'; } /** Weekday curto a partir de ISO ('seg', 'ter', ...). */ export function fmtWeekdayShort(iso) { return new Date(iso).toLocaleDateString('pt-BR', { weekday: 'short' }).replace('.', '').slice(0, 3); } /** Dia do mês a partir de ISO. */ export function fmtDayNum(iso) { return new Date(iso).getDate(); } /** Mês curto a partir de ISO ('mai', 'jun', ...). */ export function fmtMonthShort(iso) { return new Date(iso).toLocaleDateString('pt-BR', { month: 'short' }).replace('.', ''); } // ──────────────────────────────────────────────────────────────────────── // Parsers / conversores // ──────────────────────────────────────────────────────────────────────── /** "HH:MM" → minutos do dia. Trata null/inválido como 0. */ export function hhmmToMin(hhmm) { const [h, m] = String(hhmm || '00:00') .slice(0, 5) .split(':') .map(Number); return (h || 0) * 60 + (m || 0); } /** Minutos do dia → "HH:MM" zero-padded. Wrapping em 24h. */ export function minToHHMM(min) { const h = Math.floor(min / 60) % 24; const m = min % 60; return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; } /** * Extrai HH:MM de um ISO timestamp respeitando timezone: * - Se traz Z ou ±HH:MM no final → converte pra timezone local * - Se for ISO sem timezone (ex: "2026-05-15T14:30:00") → lê os * dígitos diretamente (evita drift quando o backend já mandou * hora "como deveria aparecer"). */ export function isoToHHMM(iso) { if (!iso) return null; const s = String(iso); if (s.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(s)) { const d = new Date(s); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; } const match = s.match(/T(\d{2}):(\d{2})/); if (match) return `${match[1]}:${match[2]}`; const d = new Date(s); return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; } // ──────────────────────────────────────────────────────────────────────── // Predicados // ──────────────────────────────────────────────────────────────────────── /** Data já passou (em relação ao now). null/falsy → false. */ export function isPast(iso) { return iso ? new Date(iso) < new Date() : false; } /** * Retorna true se o commitment é uma "sessão nativa" (categoria especial * que requer paciente vinculado e habilita financeiro/recorrência). * Schema: commitments.native_key = 'session' (case-insensitive). */ export function isNativeSession(c) { return String(c?.native_key || '').toLowerCase() === 'session'; } /** * Verifica se uma data está fora do plano (após dataLimiteManual). * dataLimiteManual=null → tudo dentro do plano (false). * Antes a função era impura (lia dataLimiteManual.value de ref); agora * recebe o valor explicitamente pra ser testável. */ export function isForaDoPlano(d, dataLimiteManual) { if (!dataLimiteManual) return false; return new Date(d) > new Date(dataLimiteManual); } // ──────────────────────────────────────────────────────────────────────── // Cálculos // ──────────────────────────────────────────────────────────────────────── /** Adiciona N minutos a uma data, retorna NOVA Date (não muta entrada). */ export function addMinutesDate(date, min) { const d = new Date(date); d.setMinutes(d.getMinutes() + Number(min || 0)); return d; } /** * Diferença em minutos (b - a) entre duas datas/strings ISO. * Negativos viram 0 (proteção contra range invertido). * Erros (datas inválidas) → null. */ export function calcMinutes(a, b) { try { if (!a || !b) return null; const ms = new Date(b).getTime() - new Date(a).getTime(); return Math.max(0, Math.round(ms / 60000)); } catch { return null; } } /** * Preço final de um item de billing aplicando desconto percentual e flat. * subtotal = unit_price * quantity * final = max(0, subtotal - subtotal*pct% - flat) * Garante não-negativo (descontos > subtotal viram zero). */ export function calcFinalPrice(unit_price, quantity, discount_pct, discount_flat) { const subtotal = Number(unit_price) * Number(quantity); const discPct = subtotal * (Number(discount_pct ?? 0) / 100); const discFlat = Number(discount_flat ?? 0); return Math.max(0, subtotal - discPct - discFlat); } // ──────────────────────────────────────────────────────────────────────── // Mapeamentos de status da sessão // ──────────────────────────────────────────────────────────────────────── const STATUS_LABEL_MAP = Object.freeze({ agendado: 'Agendado', realizado: 'Realizado', faltou: 'Faltou', cancelado: 'Cancelado', remarcado: 'Remarcado' }); /** Status enum → label legível. Desconhecido → '—'. */ export function labelStatusSessao(v) { return STATUS_LABEL_MAP[v] || '—'; } /** Status → severity do PrimeVue Tag (info/success/warn/danger/secondary). */ export function statusSeverity(v) { if (v === 'agendado') return 'info'; if (v === 'realizado') return 'success'; if (v === 'faltou') return 'warn'; if (v === 'cancelado') return 'danger'; if (v === 'remarcado') return 'secondary'; // cor real via classe CSS return 'secondary'; } /** * Classe CSS extra pra status que precisam de cor custom (PrimeVue * severity não tem roxo nativo). */ export function statusExtraClass(v) { return v === 'remarcado' ? 'tag-remarcado' : ''; }