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>
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| 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' : '';
|
||||
}
|
||||
Reference in New Issue
Block a user