6d9b36d592
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>
263 lines
11 KiB
JavaScript
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' : '';
|
|
}
|