Files
agenciapsilmno/src/features/agenda/composables/agendaEventHelpers.js
T
Leonardo 6d9b36d592 A66 WIP: AgendaEventDialog quebrado em 5 composables + 265 specs + V2 esqueleto
Sub-sessao 1 entregue (composables):
- agendaEventHelpers (262L) — utilitarios puros (date, format, parse)
- useAgendaEventComposer (485L) — montagem do form + validacao
- useAgendaEventActions (387L) — save/delete/cancel/move actions
- useAgendaEventPickerBilling (378L) — pickers (terapeuta, servico,
  convenio) + calculo de billing
- useAgendaEventLifecycle (474L) — open/close/dirty state + autosave
- 5 specs em __tests__/ (75+76+28+43+43 = 265 testes), 495/495 passing

AgendaEventDialog: 3522 -> 2632 linhas (-25%) consumindo os composables.
Backup byte-identico em AgendaEventDialog.vue.bak pra rollback.

Sub-sessao 2 entregue (esqueleto, NAO TESTADO):
- AgendaEventDialogV2 (~1100L, 3 zonas: PACIENTE/QUANDO/O QUE)
- Preview em /preview/agenda-dialog-v2 com 5 cenarios
- Rota em routes.misc.js
- User testou e nao gostou do design — aguarda feedback especifico
  pra iteracao na sub-sessao 3 (migracao nos 9 consumers).

Dialogs auxiliares novos pro AgendaEventDialog:
- InsurancePlanQuickCreateDialog (criar convenio inline)
- ServiceQuickCreateDialog (criar tipo de sessao inline)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:13:22 -03:00

263 lines
11 KiB
JavaScript

/*
|--------------------------------------------------------------------------
| 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' : '';
}