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:
Leonardo
2026-05-06 09:13:22 -03:00
parent 957e912a7f
commit 6d9b36d592
17 changed files with 10963 additions and 1295 deletions
@@ -0,0 +1,399 @@
/**
* agendaEventHelpers.spec.js — A66 sub-sessão 1A
*
* Cobertura dos helpers PUROS extraídos do AgendaEventDialog. Cada função
* é determinística (sem refs reativos, sem I/O) — bateria foca em:
* - happy path (entrada típica)
* - edge cases (null/undefined/'')
* - boundaries (0min, 24h wrapping, descontos > subtotal)
* - matriz de inputs nos mappers de status
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
patientInitials,
fmtBRL,
fmtJornadaHora,
fmtDateBR,
fmtDateBRLong,
fmtTime,
fmtDuracao,
fmtSerieHora,
nomeDiaSemana,
fmtWeekdayShort,
fmtDayNum,
fmtMonthShort,
hhmmToMin,
minToHHMM,
isoToHHMM,
isPast,
isNativeSession,
isForaDoPlano,
addMinutesDate,
calcMinutes,
calcFinalPrice,
labelStatusSessao,
statusSeverity,
statusExtraClass
} from '../agendaEventHelpers';
describe('patientInitials', () => {
it('extrai 2 iniciais de nome composto', () => {
expect(patientInitials('Ana Souza Ferreira')).toBe('AF');
expect(patientInitials('joão silva')).toBe('JS');
});
it('faz slice 2 do único nome quando não há sobrenome', () => {
expect(patientInitials('Maria')).toBe('MA');
});
it('retorna ? quando vazio/null', () => {
expect(patientInitials('')).toBe('?');
expect(patientInitials(null)).toBe('?');
expect(patientInitials(undefined)).toBe('?');
expect(patientInitials(' ')).toBe('?');
});
it('lida com múltiplos espaços', () => {
expect(patientInitials(' ana maria ')).toBe('AM');
});
});
describe('fmtBRL', () => {
it('formata número como BRL', () => {
expect(fmtBRL(1234.56)).toMatch(/R\$\s?1\.234,56/);
expect(fmtBRL(0)).toMatch(/R\$\s?0,00/);
});
it('null/undefined → "—"', () => {
expect(fmtBRL(null)).toBe('—');
expect(fmtBRL(undefined)).toBe('—');
});
it('0 NÃO retorna "—" (é valor válido)', () => {
expect(fmtBRL(0)).not.toBe('—');
});
});
describe('fmtJornadaHora', () => {
it('hora cheia sem minutos', () => {
expect(fmtJornadaHora('09:00')).toBe('9h');
expect(fmtJornadaHora('14:00')).toBe('14h');
});
it('hora com minutos formata "Xh{MM}"', () => {
expect(fmtJornadaHora('14:30')).toBe('14h30');
expect(fmtJornadaHora('09:05')).toBe('9h05');
});
it('lida com null/string vazia (default 00:00)', () => {
expect(fmtJornadaHora(null)).toBe('0h');
expect(fmtJornadaHora('')).toBe('0h');
});
it('aceita "HH:MM:SS" truncando segundos', () => {
expect(fmtJornadaHora('14:30:00')).toBe('14h30');
});
});
describe('fmtDuracao', () => {
it('só horas', () => {
expect(fmtDuracao(60)).toBe('1h');
expect(fmtDuracao(120)).toBe('2h');
});
it('horas + minutos', () => {
expect(fmtDuracao(90)).toBe('1h 30min');
expect(fmtDuracao(135)).toBe('2h 15min');
});
it('só minutos', () => {
expect(fmtDuracao(45)).toBe('45min');
expect(fmtDuracao(15)).toBe('15min');
});
it('0/null/undefined → "—"', () => {
expect(fmtDuracao(0)).toBe('—');
expect(fmtDuracao(null)).toBe('—');
expect(fmtDuracao(undefined)).toBe('—');
});
});
describe('fmtSerieHora', () => {
it('trunca segundos da string TIME', () => {
expect(fmtSerieHora('14:30:00')).toBe('14:30');
expect(fmtSerieHora('09:00:45')).toBe('09:00');
});
it('mantém HH:MM se sem segundos', () => {
expect(fmtSerieHora('14:30')).toBe('14:30');
});
it('null/undefined → "—"', () => {
expect(fmtSerieHora(null)).toBe('—');
expect(fmtSerieHora('')).toBe('—');
});
});
describe('nomeDiaSemana', () => {
it('mapeia 0-6 corretamente', () => {
expect(nomeDiaSemana(0)).toBe('domingo');
expect(nomeDiaSemana(1)).toBe('segunda');
expect(nomeDiaSemana(6)).toBe('sábado');
});
it('aceita string numérica', () => {
expect(nomeDiaSemana('3')).toBe('quarta');
});
it('null/undefined → "domingo" (fallback 0)', () => {
expect(nomeDiaSemana(null)).toBe('domingo');
expect(nomeDiaSemana(undefined)).toBe('domingo');
});
});
describe('fmtTime', () => {
it('formata HH:MM de Date', () => {
const d = new Date('2026-05-15T14:30:00');
expect(fmtTime(d)).toMatch(/14:30/);
});
it('formata HH:MM de string ISO', () => {
expect(fmtTime('2026-05-15T09:05:00')).toMatch(/09:05/);
});
it('null/undefined → "—"', () => {
expect(fmtTime(null)).toBe('—');
expect(fmtTime(undefined)).toBe('—');
});
});
describe('hhmmToMin', () => {
it('converte HH:MM em minutos do dia', () => {
expect(hhmmToMin('00:00')).toBe(0);
expect(hhmmToMin('01:30')).toBe(90);
expect(hhmmToMin('14:00')).toBe(840);
expect(hhmmToMin('23:59')).toBe(1439);
});
it('aceita HH:MM:SS truncando', () => {
expect(hhmmToMin('14:30:00')).toBe(870);
});
it('null/string vazia → 0', () => {
expect(hhmmToMin(null)).toBe(0);
expect(hhmmToMin('')).toBe(0);
});
});
describe('minToHHMM', () => {
it('converte minutos em HH:MM zero-padded', () => {
expect(minToHHMM(0)).toBe('00:00');
expect(minToHHMM(90)).toBe('01:30');
expect(minToHHMM(840)).toBe('14:00');
});
it('faz wrapping em 24h', () => {
expect(minToHHMM(1440)).toBe('00:00');
expect(minToHHMM(1500)).toBe('01:00');
});
// Round-trip pra cobrir simetria das duas funções
it('round-trip hhmmToMin → minToHHMM preserva valor', () => {
const inputs = ['00:00', '08:15', '14:30', '23:59'];
for (const h of inputs) {
expect(minToHHMM(hhmmToMin(h))).toBe(h);
}
});
});
describe('isoToHHMM', () => {
it('lê dígitos diretos de ISO sem timezone', () => {
expect(isoToHHMM('2026-05-15T14:30:00')).toBe('14:30');
expect(isoToHHMM('2026-05-15T09:05:30')).toBe('09:05');
});
it('null/undefined → null', () => {
expect(isoToHHMM(null)).toBe(null);
expect(isoToHHMM('')).toBe(null);
});
// ISO com Z/offset depende do timezone do sistema executando o teste,
// então só verificamos que volta uma string HH:MM válida.
it('ISO com Z retorna formato HH:MM', () => {
const result = isoToHHMM('2026-05-15T14:30:00Z');
expect(result).toMatch(/^\d{2}:\d{2}$/);
});
});
describe('isPast', () => {
let mockNow;
beforeEach(() => {
mockNow = new Date('2026-05-15T12:00:00').getTime();
vi.useFakeTimers();
vi.setSystemTime(mockNow);
});
afterEach(() => vi.useRealTimers());
it('passado → true', () => {
expect(isPast('2026-05-14T12:00:00')).toBe(true);
expect(isPast('2025-01-01')).toBe(true);
});
it('futuro → false', () => {
expect(isPast('2026-05-16T12:00:00')).toBe(false);
expect(isPast('2030-01-01')).toBe(false);
});
it('null/undefined → false', () => {
expect(isPast(null)).toBe(false);
expect(isPast(undefined)).toBe(false);
expect(isPast('')).toBe(false);
});
});
describe('isNativeSession', () => {
it('native_key="session" → true (qualquer case)', () => {
expect(isNativeSession({ native_key: 'session' })).toBe(true);
expect(isNativeSession({ native_key: 'SESSION' })).toBe(true);
expect(isNativeSession({ native_key: 'Session' })).toBe(true);
});
it('outro native_key → false', () => {
expect(isNativeSession({ native_key: 'meeting' })).toBe(false);
expect(isNativeSession({ native_key: '' })).toBe(false);
expect(isNativeSession({})).toBe(false);
});
it('null/undefined → false', () => {
expect(isNativeSession(null)).toBe(false);
expect(isNativeSession(undefined)).toBe(false);
});
});
describe('isForaDoPlano', () => {
it('limite null → tudo dentro do plano', () => {
expect(isForaDoPlano('2030-01-01', null)).toBe(false);
expect(isForaDoPlano('2030-01-01', undefined)).toBe(false);
});
it('data > limite → fora', () => {
expect(isForaDoPlano('2026-12-31', '2026-06-30')).toBe(true);
});
it('data < limite → dentro', () => {
expect(isForaDoPlano('2026-01-15', '2026-06-30')).toBe(false);
});
it('data == limite → dentro (operador é > e não >=)', () => {
expect(isForaDoPlano('2026-06-30', '2026-06-30')).toBe(false);
});
});
describe('addMinutesDate', () => {
it('soma minutos retornando NOVA data', () => {
const base = new Date('2026-05-15T10:00:00');
const result = addMinutesDate(base, 90);
expect(result.getHours()).toBe(11);
expect(result.getMinutes()).toBe(30);
// Não muta o original
expect(base.getHours()).toBe(10);
});
it('subtrai com negativos', () => {
const base = new Date('2026-05-15T10:00:00');
const result = addMinutesDate(base, -30);
expect(result.getHours()).toBe(9);
expect(result.getMinutes()).toBe(30);
});
it('aceita string ISO', () => {
const result = addMinutesDate('2026-05-15T10:00:00', 30);
expect(result.getMinutes()).toBe(30);
});
it('null minutos → mesma data (0)', () => {
const base = new Date('2026-05-15T10:00:00');
const result = addMinutesDate(base, null);
expect(result.getTime()).toBe(base.getTime());
});
});
describe('calcMinutes', () => {
it('diff positivo em minutos', () => {
expect(calcMinutes('2026-05-15T10:00:00', '2026-05-15T11:30:00')).toBe(90);
expect(calcMinutes('2026-05-15T10:00:00', '2026-05-15T10:50:00')).toBe(50);
});
it('diff negativo (b < a) → 0 (clamp)', () => {
expect(calcMinutes('2026-05-15T11:00:00', '2026-05-15T10:00:00')).toBe(0);
});
it('null/undefined → null', () => {
expect(calcMinutes(null, '2026-05-15T10:00:00')).toBe(null);
expect(calcMinutes('2026-05-15T10:00:00', null)).toBe(null);
expect(calcMinutes(null, null)).toBe(null);
});
it('mesma data → 0', () => {
expect(calcMinutes('2026-05-15T10:00:00', '2026-05-15T10:00:00')).toBe(0);
});
});
describe('calcFinalPrice', () => {
it('sem descontos = subtotal', () => {
expect(calcFinalPrice(100, 1, 0, 0)).toBe(100);
expect(calcFinalPrice(50, 3, 0, 0)).toBe(150);
expect(calcFinalPrice(100, 1, null, null)).toBe(100);
});
it('aplica desconto percentual', () => {
expect(calcFinalPrice(100, 1, 10, 0)).toBe(90);
expect(calcFinalPrice(200, 2, 25, 0)).toBe(300); // 400 - 25%
});
it('aplica desconto flat', () => {
expect(calcFinalPrice(100, 1, 0, 20)).toBe(80);
});
it('combina percentual + flat', () => {
// 100 - 10% - 5 = 100 - 10 - 5 = 85
expect(calcFinalPrice(100, 1, 10, 5)).toBe(85);
});
it('descontos > subtotal → 0 (não negativo)', () => {
expect(calcFinalPrice(100, 1, 0, 200)).toBe(0);
expect(calcFinalPrice(100, 1, 110, 0)).toBe(0);
});
});
describe('labelStatusSessao', () => {
const cases = [
['agendado', 'Agendado'],
['realizado', 'Realizado'],
['faltou', 'Faltou'],
['cancelado', 'Cancelado'],
['remarcado', 'Remarcado']
];
it.each(cases)('"%s" → "%s"', (status, expected) => {
expect(labelStatusSessao(status)).toBe(expected);
});
it('desconhecido → "—"', () => {
expect(labelStatusSessao('blablabla')).toBe('—');
expect(labelStatusSessao(null)).toBe('—');
expect(labelStatusSessao('')).toBe('—');
});
});
describe('statusSeverity', () => {
const cases = [
['agendado', 'info'],
['realizado', 'success'],
['faltou', 'warn'],
['cancelado', 'danger'],
['remarcado', 'secondary']
];
it.each(cases)('"%s" → "%s"', (status, expected) => {
expect(statusSeverity(status)).toBe(expected);
});
it('desconhecido → "secondary" (fallback)', () => {
expect(statusSeverity('blablabla')).toBe('secondary');
expect(statusSeverity(null)).toBe('secondary');
});
});
describe('statusExtraClass', () => {
it('remarcado → "tag-remarcado"', () => {
expect(statusExtraClass('remarcado')).toBe('tag-remarcado');
});
it('outros → ""', () => {
expect(statusExtraClass('agendado')).toBe('');
expect(statusExtraClass('realizado')).toBe('');
expect(statusExtraClass(null)).toBe('');
});
});
describe('fmt date helpers (Date input)', () => {
const d = new Date('2026-05-15T14:30:00');
it('fmtDateBR retorna formato dd-mes-aaaa pt-BR', () => {
const result = fmtDateBR(d);
expect(result).toMatch(/15.*mai.*2026/i);
});
it('fmtDateBRLong inclui weekday', () => {
const result = fmtDateBRLong(d);
expect(result).toMatch(/15.*mai/i);
// Pelo menos contém algum dia da semana
expect(result).toMatch(/dom|seg|ter|qua|qui|sex|s[áa]b/i);
});
it('fmtWeekdayShort retorna 3 chars', () => {
const r = fmtWeekdayShort('2026-05-15T14:30:00');
expect(r.length).toBeLessThanOrEqual(3);
});
it('fmtDayNum extrai dia do mês', () => {
expect(fmtDayNum('2026-05-15T14:30:00')).toBe(15);
});
it('fmtMonthShort sem ponto final', () => {
const r = fmtMonthShort('2026-05-15T14:30:00');
expect(r).not.toContain('.');
});
});
@@ -0,0 +1,548 @@
/**
* useAgendaEventActions.spec.js — A66 sub-sessão 1C-i
*
* Foco: handlers de save/delete e helpers puros de payload.
* Os watchers (status confirm, billingType, samePatientConflict) são
* testados indiretamente via setup do composable + mutações no form.
*
* Mock estratégia:
* - useToast/useConfirm: capturamos as chamadas em arrays/callbacks
* - supabase: mock o builder chain pra retornar { data, error }
* - composer: criamos manualmente um objeto com refs computeds que
* espelham o contrato esperado (não rodamos o composer real pra
* isolar o teste só nas actions)
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ref, computed } from 'vue';
// ── Mocks de dependências ───────────────────────────────────────────
const toastAdd = vi.fn();
vi.mock('primevue/usetoast', () => ({
useToast: () => ({ add: toastAdd })
}));
let _confirmAccept = null;
let _confirmReject = null;
let _confirmCalls = [];
const confirmRequire = vi.fn((opts) => {
_confirmCalls.push(opts);
_confirmAccept = opts.accept;
_confirmReject = opts.reject;
});
vi.mock('primevue/useconfirm', () => ({
useConfirm: () => ({ require: confirmRequire })
}));
const supabaseUpdateMock = vi.fn();
const supabaseSelectMock = vi.fn();
vi.mock('@/lib/supabase/client', () => ({
supabase: {
from: vi.fn(() => ({
update: (...args) => {
supabaseUpdateMock(...args);
return {
eq: () => ({
select: () => ({
single: () => Promise.resolve({ data: { id: 'evt-1', status: 'cancelado' }, error: null })
})
})
};
},
select: (...args) => {
supabaseSelectMock(...args);
return {
eq: () => ({
gte: () => ({
lt: () => ({
limit: () => ({
neq: () => ({ maybeSingle: () => Promise.resolve({ data: null }) }),
maybeSingle: () => Promise.resolve({ data: null })
})
})
})
})
};
}
}))
}
}));
const { useAgendaEventActions } = await import('../useAgendaEventActions.js');
// ── Helper: composer fake com o contrato mínimo ─────────────────────
function makeComposer(overrides = {}) {
const form = ref({
id: null,
owner_id: 'owner-1',
terapeuta_id: null,
paciente_id: null,
paciente_nome: '',
paciente_status: '',
commitment_id: 'c-1',
titulo_custom: '',
status: 'agendado',
observacoes: '',
dia: new Date('2026-05-15T00:00:00'),
startTime: '14:00',
duracaoMin: 50,
modalidade: 'presencial',
extra_fields: {},
price: null,
insurance_plan_id: null,
insurance_guide_number: null,
insurance_value: null,
insurance_plan_service_id: null,
...(overrides.formExtra || {})
});
const inicioDateTime = computed(() => {
if (!form.value.dia || !form.value.startTime) return null;
const d = new Date(form.value.dia);
const [h, m] = form.value.startTime.split(':').map(Number);
d.setHours(h, m, 0, 0);
return d;
});
const fimDateTime = computed(() => {
if (!inicioDateTime.value) return null;
const d = new Date(inicioDateTime.value);
d.setMinutes(d.getMinutes() + (form.value.duracaoMin || 50));
return d;
});
return {
form,
canSave: ref(true),
timeConflict: ref(null),
isEdit: ref(false),
isSessionEvent: ref(true),
requiresPatient: ref(false),
hasSerie: ref(false),
billingType: ref('particular'),
recorrenciaType: ref('avulsa'),
diaSemanaRecorrencia: ref(0),
diasSelecionados: ref([]),
dataFimCalculada: ref(null),
qtdSessoesEfetiva: ref(4),
ocorrenciasComConflito: ref([]),
editScope: ref('somente_este'),
editScopeOptions: ref([
{ value: 'somente_este', label: 'Somente esta' },
{ value: 'todos', label: 'Todas' }
]),
visible: ref(true),
inicioDateTime,
fimDateTime,
computedTitulo: ref('Sessão'),
...overrides
};
}
function setup(composerOverrides = {}, propsOverrides = {}) {
toastAdd.mockClear();
confirmRequire.mockClear();
_confirmCalls = [];
_confirmAccept = null;
_confirmReject = null;
const composer = makeComposer(composerOverrides);
const commitmentItems = ref([]);
const servicePickerSel = ref(null);
const selectedPlanService = ref(null);
const saveCommitmentItems = vi.fn().mockResolvedValue();
const props = { eventRow: null, ...propsOverrides };
const emitted = [];
const emit = (...args) => emitted.push(args);
const actions = useAgendaEventActions({
composer,
commitmentItems,
servicePickerSel,
selectedPlanService,
saveCommitmentItems,
props,
emit
});
return { composer, commitmentItems, servicePickerSel, selectedPlanService, props, emit, emitted, actions, saveCommitmentItems };
}
// ════════════════════════════════════════════════════════════════════
describe('buildSavePayload (helper puro)', () => {
it('monta payload com session', () => {
const { actions } = setup();
const payload = actions.buildSavePayload({
form: {
owner_id: 'o-1',
terapeuta_id: 't-1',
paciente_id: 'p-1',
status: 'agendado',
modalidade: 'presencial',
observacoes: 'note',
commitment_id: 'c-1',
titulo_custom: '',
extra_fields: {},
price: 100,
insurance_plan_id: null,
insurance_guide_number: null,
insurance_value: null,
insurance_plan_service_id: null
},
requiresPatient: true,
isSessionEvent: true,
computedTitulo: 'Ana [Sessão]',
inicioISO: '2026-05-15T14:00:00.000Z',
fimISO: '2026-05-15T14:50:00.000Z'
});
expect(payload).toMatchObject({
owner_id: 'o-1',
paciente_id: 'p-1',
patient_id: 'p-1',
tipo: 'sessao',
status: 'agendado',
titulo: 'Ana [Sessão]',
inicio_em: '2026-05-15T14:00:00.000Z',
determined_commitment_id: 'c-1',
price: 100
});
});
it('paciente_id fica null quando NÃO é session', () => {
const { actions } = setup();
const payload = actions.buildSavePayload({
form: { paciente_id: 'p-1', commitment_id: 'c-meeting' },
requiresPatient: false,
isSessionEvent: false,
computedTitulo: 'Reunião',
inicioISO: 'X',
fimISO: 'Y'
});
expect(payload.paciente_id).toBe(null);
expect(payload.patient_id).toBe(null);
expect(payload.price).toBe(null); // só session salva price
});
it('extra_fields=null quando objeto vazio', () => {
const { actions } = setup();
const payload = actions.buildSavePayload({
form: { extra_fields: {} },
requiresPatient: false,
isSessionEvent: false,
computedTitulo: '',
inicioISO: 'X',
fimISO: 'Y'
});
expect(payload.extra_fields).toBe(null);
});
it('extra_fields preservado quando tem chaves', () => {
const { actions } = setup();
const payload = actions.buildSavePayload({
form: { extra_fields: { custom: 'v' } },
requiresPatient: false,
isSessionEvent: false,
computedTitulo: '',
inicioISO: 'X',
fimISO: 'Y'
});
expect(payload.extra_fields).toEqual({ custom: 'v' });
});
});
describe('buildRecorrenciaPayload (helper puro)', () => {
it('avulsa → null', () => {
const { actions } = setup();
expect(
actions.buildRecorrenciaPayload({
recorrenciaType: 'avulsa',
diaSemanaRecorrencia: 0,
diasSelecionados: [],
startTime: '14:00',
duracaoMin: 50,
dataFimCalculada: null,
qtdSessoesEfetiva: 0,
serieValorMode: 'multiplicar',
commitmentItemsList: [],
ocorrenciasComConflito: []
})
).toBe(null);
});
it('semanal monta payload completo', () => {
const { actions } = setup();
const dataFim = new Date('2026-06-05T00:00:00.000Z');
const result = actions.buildRecorrenciaPayload({
recorrenciaType: 'semanal',
diaSemanaRecorrencia: 5,
diasSelecionados: [],
startTime: '14:00',
duracaoMin: 50,
dataFimCalculada: dataFim,
qtdSessoesEfetiva: 4,
serieValorMode: 'multiplicar',
commitmentItemsList: [{ id: 'i-1' }],
ocorrenciasComConflito: []
});
expect(result).toMatchObject({
tipo: 'recorrente',
tipoFreq: 'semanal',
diaSemana: 5,
horaInicio: '14:00:00',
duracaoMin: 50,
qtdSessoes: 4,
serieValorMode: 'multiplicar',
commitmentItems: [{ id: 'i-1' }]
});
expect(result.dataFim).toBe(dataFim.toISOString());
});
it('inclui só ocorrências COM conflict no array conflitos', () => {
const { actions } = setup();
const result = actions.buildRecorrenciaPayload({
recorrenciaType: 'semanal',
diaSemanaRecorrencia: 5,
diasSelecionados: [],
startTime: '14:00',
duracaoMin: 50,
dataFimCalculada: new Date(),
qtdSessoesEfetiva: 4,
serieValorMode: 'multiplicar',
commitmentItemsList: [],
ocorrenciasComConflito: [
{ date: new Date('2026-05-15'), conflict: { type: 'feriado', label: 'X' } },
{ date: new Date('2026-05-22'), conflict: null },
{ date: new Date('2026-05-29'), conflict: { type: 'pausa', label: 'Y' } }
]
});
expect(result.conflitos).toHaveLength(2);
expect(result.conflitos[0].conflict.type).toBe('feriado');
expect(result.conflitos[1].conflict.type).toBe('pausa');
});
});
describe('onSave', () => {
it('aborta se !canSave (não emite)', () => {
const { actions, emitted } = setup({ canSave: ref(false) });
actions.onSave();
expect(emitted).toHaveLength(0);
});
it('aborta com toast se timeConflict presente', () => {
const { actions, emitted } = setup({ timeConflict: ref('Conflito com Maria às 14:30') });
actions.onSave();
expect(emitted).toHaveLength(0);
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'warn',
summary: 'Conflito de horário',
detail: expect.stringContaining('Conflito com Maria')
})
);
});
it('emite save com payload e recorrencia=null em avulsa', () => {
const { actions, emitted } = setup();
actions.onSave();
expect(emitted).toHaveLength(1);
const [name, body] = emitted[0];
expect(name).toBe('save');
expect(body.recorrencia).toBe(null);
expect(body.payload.tipo).toBe('sessao');
});
it('emite save com recorrencia preenchida em recorrência', () => {
const { actions, emitted } = setup({
recorrenciaType: ref('semanal'),
qtdSessoesEfetiva: ref(4),
dataFimCalculada: ref(new Date('2026-06-05'))
});
actions.onSave();
const [, body] = emitted[0];
expect(body.recorrencia).not.toBe(null);
expect(body.recorrencia.tipoFreq).toBe('semanal');
expect(body.recorrencia.qtdSessoes).toBe(4);
});
it('inclui editMode/recurrence_id/original_date quando editando série', () => {
const props = {
eventRow: { recurrence_id: 'r-1', original_date: '2026-05-15' }
};
const { actions, emitted } = setup({ hasSerie: ref(true), editScope: ref('todos') }, props);
actions.onSave();
const [, body] = emitted[0];
expect(body.editMode).toBe('todos');
expect(body.recurrence_id).toBe('r-1');
expect(body.original_date).toBe('2026-05-15');
});
it('serviceItems e onSaved presentes só em sessão', () => {
const { actions, emitted, commitmentItems, saveCommitmentItems } = setup();
commitmentItems.value = [{ service_id: 's-1', quantity: 2 }];
actions.onSave();
const [, body] = emitted[0];
expect(body.serviceItems).toEqual([{ service_id: 's-1', quantity: 2 }]);
expect(typeof body.onSaved).toBe('function');
// Simula chamada do onSaved
body.onSaved('evt-1', { markCustomized: true });
expect(saveCommitmentItems).toHaveBeenCalledWith('evt-1', expect.any(Array), { markCustomized: true });
});
it('serviceItems e onSaved são null em não-session', () => {
const { actions, emitted } = setup({ isSessionEvent: ref(false) });
actions.onSave();
const [, body] = emitted[0];
expect(body.serviceItems).toBe(null);
expect(body.onSaved).toBe(null);
});
});
describe('onDelete', () => {
it('no-op sem form.id', () => {
const { actions, emitted } = setup();
actions.onDelete();
expect(emitted).toHaveLength(0);
expect(confirmRequire).not.toHaveBeenCalled();
});
it('avulsa: confirm + emit(id)', () => {
const { composer, actions, emitted } = setup();
composer.form.value.id = 'evt-1';
actions.onDelete();
expect(confirmRequire).toHaveBeenCalled();
// Aceitar dispara emit
_confirmAccept();
expect(emitted).toContainEqual(['delete', 'evt-1']);
});
it('série: confirm com escopo + emit({id, editMode, ...})', () => {
const props = { eventRow: { recurrence_id: 'r-1', original_date: '2026-05-15' } };
const { composer, actions, emitted } = setup({ hasSerie: ref(true), editScope: ref('este_e_seguintes') }, props);
composer.form.value.id = 'evt-1';
actions.onDelete();
_confirmAccept();
expect(emitted).toHaveLength(1);
const [name, body] = emitted[0];
expect(name).toBe('delete');
expect(body).toMatchObject({
id: 'evt-1',
editMode: 'este_e_seguintes',
recurrence_id: 'r-1',
original_date: '2026-05-15'
});
});
it('série + editScope=todos: usa header "Encerrar toda a série"', () => {
const { composer, actions } = setup({ hasSerie: ref(true), editScope: ref('todos') });
composer.form.value.id = 'evt-1';
actions.onDelete();
const opts = _confirmCalls[0];
expect(opts.header).toMatch(/Encerrar toda/);
});
});
describe('onEncerrarSerie', () => {
it('confirma encerramento e emite com editMode=todos', () => {
const props = { eventRow: { recurrence_id: 'r-2', original_date: '2026-05-22' } };
const { composer, actions, emitted } = setup({}, props);
composer.form.value.id = 'evt-2';
actions.onEncerrarSerie();
expect(confirmRequire).toHaveBeenCalled();
_confirmAccept();
const [name, body] = emitted[0];
expect(name).toBe('delete');
expect(body.editMode).toBe('todos');
expect(body.recurrence_id).toBe('r-2');
});
});
describe('Watcher: billingType', () => {
it('gratuito limpa items, price=0 e campos de convênio', async () => {
const { composer, commitmentItems } = setup({
billingType: ref('particular')
});
commitmentItems.value = [{ id: 'i-1' }];
composer.form.value.price = 100;
composer.form.value.insurance_plan_id = 'plan-1';
composer.billingType.value = 'gratuito';
await new Promise((r) => setTimeout(r, 0)); // flush watcher
expect(commitmentItems.value).toEqual([]);
expect(composer.form.value.price).toBe(0);
expect(composer.form.value.insurance_plan_id).toBe(null);
});
it('particular limpa só campos de convênio', async () => {
const { composer, commitmentItems } = setup({ billingType: ref('convenio') });
commitmentItems.value = [{ id: 'i-1' }];
composer.form.value.insurance_plan_id = 'plan-1';
composer.billingType.value = 'particular';
await new Promise((r) => setTimeout(r, 0));
// particular preserva itens (não limpa); só limpa convenio
expect(composer.form.value.insurance_plan_id).toBe(null);
});
it('convenio limpa items e servicePickerSel', async () => {
const { composer, commitmentItems, servicePickerSel } = setup({ billingType: ref('particular') });
commitmentItems.value = [{ id: 'i-1' }];
servicePickerSel.value = 'svc-x';
composer.billingType.value = 'convenio';
await new Promise((r) => setTimeout(r, 0));
expect(commitmentItems.value).toEqual([]);
expect(servicePickerSel.value).toBe(null);
});
});
describe('Watcher: form.status (cancelado/remarcado)', () => {
it('NÃO dispara confirm em status comuns (agendado, realizado)', async () => {
const { composer } = setup({ isEdit: ref(true) });
composer.form.value.id = 'evt-1';
composer.form.value.status = 'realizado';
await new Promise((r) => setTimeout(r, 0));
expect(confirmRequire).not.toHaveBeenCalled();
});
it('dispara confirm em "cancelado"', async () => {
const { composer } = setup({ isEdit: ref(true) });
composer.form.value.id = 'evt-1';
composer.form.value.status = 'cancelado';
await new Promise((r) => setTimeout(r, 0));
expect(confirmRequire).toHaveBeenCalled();
const opts = _confirmCalls[0];
expect(opts.header).toMatch(/Cancelar/);
});
it('dispara confirm em "remarcado"', async () => {
const { composer } = setup({ isEdit: ref(true) });
composer.form.value.id = 'evt-1';
composer.form.value.status = 'remarcado';
await new Promise((r) => setTimeout(r, 0));
expect(confirmRequire).toHaveBeenCalled();
expect(_confirmCalls[0].header).toMatch(/Remarcar/);
});
it('reverte status no reject', async () => {
const { composer } = setup({ isEdit: ref(true) });
composer.form.value.id = 'evt-1';
composer.form.value.status = 'agendado';
await new Promise((r) => setTimeout(r, 0));
composer.form.value.status = 'cancelado';
await new Promise((r) => setTimeout(r, 0));
_confirmReject();
expect(composer.form.value.status).toBe('agendado');
});
it('NÃO dispara se !isEdit', async () => {
const { composer } = setup({ isEdit: ref(false) });
composer.form.value.status = 'cancelado';
await new Promise((r) => setTimeout(r, 0));
expect(confirmRequire).not.toHaveBeenCalled();
});
it('NÃO dispara se _skipStatusWatch ativo', async () => {
const { composer, actions } = setup({ isEdit: ref(true) });
composer.form.value.id = 'evt-1';
actions._skipStatusWatch.value = true;
composer.form.value.status = 'cancelado';
await new Promise((r) => setTimeout(r, 0));
expect(confirmRequire).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,701 @@
/**
* useAgendaEventComposer.spec.js — A66 sub-sessão 1B
*
* Cobre o composable factory extraído do AgendaEventDialog.
* Foco do contrato: refs reativos + computeds derivados são consistentes
* com o comportamento original do .vue (matriz de inputs, edge cases).
*
* Não cobre: watchers e handlers (1C — fica no .vue ainda).
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ref, reactive } from 'vue';
import { useAgendaEventComposer } from '../useAgendaEventComposer';
// Mock de getPatientAgendaPermissions — o composable importa direto
vi.mock('@/composables/usePatientLifecycle', () => ({
getPatientAgendaPermissions: (status) => {
const norm = String(status || '').toLowerCase();
if (norm === 'inativo' || norm === 'arquivado') {
return { canCreateSession: false, canCreateRecurrence: false };
}
return { canCreateSession: true, canCreateRecurrence: true };
}
}));
// Helper: monta props default + emit fake
function setup(overrides = {}, extras = {}) {
const props = reactive({
modelValue: false,
eventRow: null,
initialStartISO: '',
initialEndISO: '',
ownerId: 'owner-1',
planOwnerId: '',
allowOwnerEdit: false,
ownerOptions: [],
tenantId: 'tenant-1',
commitmentOptions: [],
presetCommitmentId: null,
lockCommitment: false,
restrictPatientsToOwner: false,
patientScopeOwnerId: null,
workRules: [],
blockedDates: [],
agendaSettings: { session_duration_min: 50, session_break_min: 0 },
allEvents: [],
pausasSemanais: [],
feriados: [],
newPatientRoute: '',
...overrides
});
const emitted = [];
const emit = (...args) => emitted.push(args);
const composer = useAgendaEventComposer(props, emit, extras);
return { props, emit, emitted, composer };
}
const SESSION_COMMITMENT = { id: 'c-session', native_key: 'session', name: 'Sessão' };
const MEETING_COMMITMENT = { id: 'c-meeting', native_key: 'meeting', name: 'Reunião' };
// ════════════════════════════════════════════════════════════════════════
describe('visible (v-model)', () => {
it('lê de props.modelValue', () => {
const { composer, props } = setup({ modelValue: true });
expect(composer.visible.value).toBe(true);
props.modelValue = false;
expect(composer.visible.value).toBe(false);
});
it('escrever emite update:modelValue', () => {
const { composer, emitted } = setup();
composer.visible.value = true;
expect(emitted).toContainEqual(['update:modelValue', true]);
});
});
describe('isEdit', () => {
it('false sem eventRow', () => {
const { composer } = setup({ eventRow: null });
expect(composer.isEdit.value).toBe(false);
});
it('true com id', () => {
const { composer } = setup({ eventRow: { id: 'evt-1' } });
expect(composer.isEdit.value).toBe(true);
});
it('true com is_occurrence (sem id)', () => {
const { composer } = setup({ eventRow: { is_occurrence: true } });
expect(composer.isEdit.value).toBe(true);
});
});
describe('allowBack', () => {
it('true por default', () => {
const { composer } = setup();
expect(composer.allowBack.value).toBe(true);
});
it('false quando lockCommitment', () => {
const { composer } = setup({ lockCommitment: true });
expect(composer.allowBack.value).toBe(false);
});
it('false quando presetCommitmentId', () => {
const { composer } = setup({ presetCommitmentId: 'c-1' });
expect(composer.allowBack.value).toBe(false);
});
});
describe('hasSerie', () => {
it('false sem indicadores de série', () => {
const { composer } = setup({ eventRow: { id: 'evt-1' } });
expect(composer.hasSerie.value).toBe(false);
});
it('true com recurrence_id', () => {
const { composer } = setup({ eventRow: { id: 'evt-1', recurrence_id: 'r-1' } });
expect(composer.hasSerie.value).toBe(true);
});
it('true com serie_id (legado)', () => {
const { composer } = setup({ eventRow: { id: 'evt-1', serie_id: 's-1' } });
expect(composer.hasSerie.value).toBe(true);
});
it('true com is_occurrence', () => {
const { composer } = setup({ eventRow: { is_occurrence: true } });
expect(composer.hasSerie.value).toBe(true);
});
});
describe('isFirstOccurrence', () => {
it('false quando não é série', () => {
const { composer } = setup();
expect(composer.isFirstOccurrence.value).toBe(false);
});
it('true quando recurrence_date é a menor data da série', () => {
const serieEvents = ref([
{ recurrence_date: '2026-05-15' },
{ recurrence_date: '2026-05-22' },
{ recurrence_date: '2026-05-29' }
]);
const { composer } = setup(
{ eventRow: { id: 'evt-1', recurrence_id: 'r-1', recurrence_date: '2026-05-15' } },
{ serieEvents }
);
expect(composer.isFirstOccurrence.value).toBe(true);
});
it('false quando recurrence_date NÃO é a menor', () => {
const serieEvents = ref([
{ recurrence_date: '2026-05-15' },
{ recurrence_date: '2026-05-22' }
]);
const { composer } = setup(
{ eventRow: { id: 'evt-1', recurrence_id: 'r-1', recurrence_date: '2026-05-22' } },
{ serieEvents }
);
expect(composer.isFirstOccurrence.value).toBe(false);
});
it('false quando serieEvents vazio', () => {
const { composer } = setup(
{ eventRow: { id: 'evt-1', recurrence_id: 'r-1', recurrence_date: '2026-05-15' } },
{ serieEvents: ref([]) }
);
expect(composer.isFirstOccurrence.value).toBe(false);
});
});
describe('editScopeOptions', () => {
it('retorna 4 opções', () => {
const { composer } = setup();
expect(composer.editScopeOptions.value).toHaveLength(4);
});
it('"este_e_seguintes" disabled quando isFirstOccurrence', () => {
const serieEvents = ref([{ recurrence_date: '2026-05-15' }, { recurrence_date: '2026-05-22' }]);
const { composer } = setup(
{ eventRow: { id: 'evt-1', recurrence_id: 'r-1', recurrence_date: '2026-05-15' } },
{ serieEvents }
);
const opt = composer.editScopeOptions.value.find((o) => o.value === 'este_e_seguintes');
expect(opt.disabled).toBe(true);
});
});
describe('qtdSessoesEfetiva', () => {
it('valores fixos pelo mode', () => {
const { composer } = setup();
composer.qtdSessoesMode.value = '4';
expect(composer.qtdSessoesEfetiva.value).toBe(4);
composer.qtdSessoesMode.value = '8';
expect(composer.qtdSessoesEfetiva.value).toBe(8);
composer.qtdSessoesMode.value = '12';
expect(composer.qtdSessoesEfetiva.value).toBe(12);
});
it('"personalizar" usa qtdSessoesCustom', () => {
const { composer } = setup();
composer.qtdSessoesMode.value = 'personalizar';
composer.qtdSessoesCustom.value = 24;
expect(composer.qtdSessoesEfetiva.value).toBe(24);
});
it('clampa em 1 quando custom é 0/null', () => {
const { composer } = setup();
composer.qtdSessoesMode.value = 'personalizar';
composer.qtdSessoesCustom.value = 0;
expect(composer.qtdSessoesEfetiva.value).toBe(1);
composer.qtdSessoesCustom.value = null;
expect(composer.qtdSessoesEfetiva.value).toBe(1);
});
});
describe('proximasOcorrencias', () => {
function asISODate(d) {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
it('retorna [] quando avulsa', () => {
const { composer } = setup();
composer.recorrenciaType.value = 'avulsa';
composer.form.value.dia = new Date('2026-05-15');
expect(composer.proximasOcorrencias.value).toEqual([]);
});
it('semanal: 4 datas separadas por 7 dias', () => {
const { composer } = setup();
composer.recorrenciaType.value = 'semanal';
composer.qtdSessoesMode.value = '4';
composer.form.value.dia = new Date('2026-05-15T10:00:00');
const list = composer.proximasOcorrencias.value;
expect(list).toHaveLength(4);
expect(asISODate(list[0])).toBe('2026-05-15');
expect(asISODate(list[1])).toBe('2026-05-22');
expect(asISODate(list[2])).toBe('2026-05-29');
expect(asISODate(list[3])).toBe('2026-06-05');
});
it('quinzenal: separação 14 dias', () => {
const { composer } = setup();
composer.recorrenciaType.value = 'quinzenal';
composer.qtdSessoesMode.value = '4';
composer.form.value.dia = new Date('2026-05-15T10:00:00');
const list = composer.proximasOcorrencias.value;
expect(list).toHaveLength(4);
expect(asISODate(list[1])).toBe('2026-05-29');
expect(asISODate(list[2])).toBe('2026-06-12');
});
it('diasEspecificos: respeita dias selecionados', () => {
const { composer } = setup();
composer.recorrenciaType.value = 'diasEspecificos';
composer.qtdSessoesMode.value = '4';
composer.diasSelecionados.value = [1, 3]; // segunda + quarta
composer.form.value.dia = new Date('2026-05-18T10:00:00'); // segunda
const list = composer.proximasOcorrencias.value;
expect(list).toHaveLength(4);
// 18 (seg) - 20 (qua) - 25 (seg) - 27 (qua)
expect(list[0].getDay()).toBe(1);
expect(list[1].getDay()).toBe(3);
expect(list[2].getDay()).toBe(1);
expect(list[3].getDay()).toBe(3);
});
it('diasEspecificos com array vazio retorna []', () => {
const { composer } = setup();
composer.recorrenciaType.value = 'diasEspecificos';
composer.diasSelecionados.value = [];
composer.form.value.dia = new Date('2026-05-15');
expect(composer.proximasOcorrencias.value).toEqual([]);
});
});
describe('toggleDiaSelecionado', () => {
it('adiciona se não existe, remove se existe', () => {
const { composer } = setup();
composer.toggleDiaSelecionado(1);
expect(composer.diasSelecionados.value).toEqual([1]);
composer.toggleDiaSelecionado(3);
expect(composer.diasSelecionados.value).toEqual([1, 3]);
composer.toggleDiaSelecionado(1);
expect(composer.diasSelecionados.value).toEqual([3]);
});
});
describe('isForaDoPlano (com dataLimiteManual)', () => {
it('false quando dataLimiteManual null', () => {
const { composer } = setup();
expect(composer.isForaDoPlano(new Date('2026-12-31'))).toBe(false);
});
it('true quando data > limite', () => {
const { composer } = setup();
composer.dataLimiteManual.value = '2026-06-30';
expect(composer.isForaDoPlano(new Date('2026-12-31'))).toBe(true);
expect(composer.isForaDoPlano(new Date('2026-06-29'))).toBe(false);
});
});
describe('commitmentCards', () => {
it('coloca native_key="session" primeiro', () => {
const { composer } = setup({
commitmentOptions: [MEETING_COMMITMENT, SESSION_COMMITMENT, { id: 'c-3', name: 'Avaliação' }]
});
const cards = composer.commitmentCards.value;
// commitmentOptions vem via reactive(props) — itens viram proxies,
// por isso comparamos id (toBe) em vez de identidade do objeto.
expect(cards[0].id).toBe('c-session');
});
it('ordem alfabética entre não-session', () => {
const { composer } = setup({
commitmentOptions: [
{ id: 'c-1', name: 'Zeta' },
{ id: 'c-2', name: 'Alpha' },
{ id: 'c-3', name: 'Beta' }
]
});
const cards = composer.commitmentCards.value;
expect(cards.map((c) => c.name)).toEqual(['Alpha', 'Beta', 'Zeta']);
});
});
describe('selectedCommitment + relacionados', () => {
it('selectedCommitment encontra pelo form.commitment_id', () => {
const { composer } = setup({
commitmentOptions: [SESSION_COMMITMENT, MEETING_COMMITMENT]
});
composer.form.value.commitment_id = 'c-meeting';
expect(composer.selectedCommitment.value?.id).toBe('c-meeting');
});
it('selectedCommitmentName fallback "—" quando null', () => {
const { composer } = setup();
expect(composer.selectedCommitmentName.value).toBe('—');
});
it('requiresPatient true quando native_key=session', () => {
const { composer } = setup({ commitmentOptions: [SESSION_COMMITMENT] });
composer.form.value.commitment_id = 'c-session';
expect(composer.requiresPatient.value).toBe(true);
expect(composer.isSessionEvent.value).toBe(true);
});
it('requiresPatient false pra outros commitments', () => {
const { composer } = setup({ commitmentOptions: [MEETING_COMMITMENT] });
composer.form.value.commitment_id = 'c-meeting';
expect(composer.requiresPatient.value).toBe(false);
});
it('patientLocked true só na edição de sessão com paciente', () => {
const { composer } = setup({
commitmentOptions: [SESSION_COMMITMENT],
eventRow: { id: 'evt-1', paciente_id: 'p-1' }
});
composer.form.value.commitment_id = 'c-session';
expect(composer.patientLocked.value).toBe(true);
});
it('hasInsurance reage a form.insurance_plan_id', () => {
const { composer } = setup();
expect(composer.hasInsurance.value).toBe(false);
composer.form.value.insurance_plan_id = 'plan-1';
expect(composer.hasInsurance.value).toBe(true);
});
});
describe('agendaPerms', () => {
it('Inativo bloqueia create', () => {
const { composer } = setup();
composer.form.value.paciente_status = 'Inativo';
expect(composer.agendaPerms.value.canCreateSession).toBe(false);
});
it('Ativo permite tudo', () => {
const { composer } = setup();
composer.form.value.paciente_status = 'Ativo';
expect(composer.agendaPerms.value.canCreateSession).toBe(true);
expect(composer.agendaPerms.value.canCreateRecurrence).toBe(true);
});
});
describe('isSessionFuture / isArchivedPastEdit / isInativoFutureEdit', () => {
let mockNow;
beforeEach(() => {
mockNow = new Date('2026-05-15T12:00:00').getTime();
vi.useFakeTimers();
vi.setSystemTime(mockNow);
});
it('isSessionFuture true quando criando (não-edit)', () => {
const { composer } = setup({ eventRow: null });
expect(composer.isSessionFuture.value).toBe(true);
});
it('isSessionFuture true quando edit + sessão futura', () => {
const { composer } = setup({
eventRow: { id: 'evt-1', inicio_em: '2026-05-20T10:00:00' }
});
expect(composer.isSessionFuture.value).toBe(true);
});
it('isSessionFuture false quando edit + sessão passada', () => {
const { composer } = setup({
eventRow: { id: 'evt-1', inicio_em: '2026-05-10T10:00:00' }
});
expect(composer.isSessionFuture.value).toBe(false);
});
it('isArchivedPastEdit true: edit + Arquivado + passada', () => {
const { composer } = setup({
eventRow: { id: 'evt-1', inicio_em: '2026-05-10T10:00:00' }
});
composer.form.value.paciente_status = 'Arquivado';
expect(composer.isArchivedPastEdit.value).toBe(true);
});
it('isInativoFutureEdit true: edit + Inativo + futura', () => {
const { composer } = setup({
eventRow: { id: 'evt-1', inicio_em: '2026-05-20T10:00:00' }
});
composer.form.value.paciente_status = 'Inativo';
expect(composer.isInativoFutureEdit.value).toBe(true);
});
});
describe('inicioDateTime / fimDateTime', () => {
it('null quando dia ou startTime ausentes', () => {
const { composer } = setup();
composer.form.value.dia = null;
expect(composer.inicioDateTime.value).toBe(null);
});
it('combina dia + startTime corretamente', () => {
const { composer } = setup();
composer.form.value.dia = new Date('2026-05-15T00:00:00');
composer.form.value.startTime = '14:30';
const ini = composer.inicioDateTime.value;
expect(ini.getHours()).toBe(14);
expect(ini.getMinutes()).toBe(30);
});
it('fimDateTime adiciona duracaoMin', () => {
const { composer } = setup();
composer.form.value.dia = new Date('2026-05-15T00:00:00');
composer.form.value.startTime = '14:00';
composer.form.value.duracaoMin = 50;
const fim = composer.fimDateTime.value;
expect(fim.getHours()).toBe(14);
expect(fim.getMinutes()).toBe(50);
});
});
describe('startTimeDate (computed get/set)', () => {
it('getter null quando startTime null', () => {
const { composer } = setup();
composer.form.value.startTime = null;
expect(composer.startTimeDate.value).toBe(null);
});
it('getter retorna Date com hora setada', () => {
const { composer } = setup();
composer.form.value.startTime = '09:30';
const d = composer.startTimeDate.value;
expect(d.getHours()).toBe(9);
expect(d.getMinutes()).toBe(30);
});
it('setter atualiza form.startTime no formato HH:MM', () => {
const { composer } = setup();
const d = new Date();
d.setHours(15, 5, 0, 0);
composer.startTimeDate.value = d;
expect(composer.form.value.startTime).toBe('15:05');
});
it('setter null limpa form.startTime', () => {
const { composer } = setup();
composer.form.value.startTime = '14:00';
composer.startTimeDate.value = null;
expect(composer.form.value.startTime).toBe(null);
});
});
describe('canSave (matriz de validação — núcleo)', () => {
function ready(composer) {
composer.form.value.owner_id = 'owner-1';
composer.form.value.dia = new Date('2026-05-15');
composer.form.value.startTime = '14:00';
composer.form.value.commitment_id = 'c-meeting';
composer.form.value.duracaoMin = 50;
}
it('false sem owner_id', () => {
const { composer } = setup({ commitmentOptions: [MEETING_COMMITMENT] });
ready(composer);
composer.form.value.owner_id = '';
expect(composer.canSave.value).toBe(false);
});
it('false sem dia', () => {
const { composer } = setup({ commitmentOptions: [MEETING_COMMITMENT] });
ready(composer);
composer.form.value.dia = null;
expect(composer.canSave.value).toBe(false);
});
it('false sem startTime', () => {
const { composer } = setup({ commitmentOptions: [MEETING_COMMITMENT] });
ready(composer);
composer.form.value.startTime = null;
expect(composer.canSave.value).toBe(false);
});
it('false ao criar sem commitment_id', () => {
const { composer } = setup({ commitmentOptions: [MEETING_COMMITMENT] });
ready(composer);
composer.form.value.commitment_id = null;
expect(composer.canSave.value).toBe(false);
});
it('true ao EDITAR sem commitment_id (sessões antigas)', () => {
const { composer } = setup({
commitmentOptions: [MEETING_COMMITMENT],
eventRow: { id: 'evt-1', inicio_em: '2026-05-15T14:00:00' }
});
ready(composer);
composer.form.value.commitment_id = null; // legacy null
expect(composer.canSave.value).toBe(true);
});
it('false em sessão sem paciente_id quando requiresPatient', () => {
const { composer } = setup({ commitmentOptions: [SESSION_COMMITMENT] });
ready(composer);
composer.form.value.commitment_id = 'c-session';
composer.form.value.paciente_id = null;
expect(composer.canSave.value).toBe(false);
});
it('false em sessão particular sem itens de billing', () => {
const items = ref([]);
const { composer } = setup(
{ commitmentOptions: [SESSION_COMMITMENT] },
{ commitmentItems: items }
);
ready(composer);
composer.form.value.commitment_id = 'c-session';
composer.form.value.paciente_id = 'p-1';
composer.billingType.value = 'particular';
expect(composer.canSave.value).toBe(false);
});
it('true em sessão particular COM itens', () => {
const items = ref([{ id: 'i-1' }]);
const { composer } = setup(
{ commitmentOptions: [SESSION_COMMITMENT] },
{ commitmentItems: items }
);
ready(composer);
composer.form.value.commitment_id = 'c-session';
composer.form.value.paciente_id = 'p-1';
composer.billingType.value = 'particular';
expect(composer.canSave.value).toBe(true);
});
it('false ao criar sessão pra paciente Inativo', () => {
const items = ref([{ id: 'i-1' }]);
const { composer } = setup(
{ commitmentOptions: [SESSION_COMMITMENT] },
{ commitmentItems: items }
);
ready(composer);
composer.form.value.commitment_id = 'c-session';
composer.form.value.paciente_id = 'p-1';
composer.form.value.paciente_status = 'Inativo';
composer.billingType.value = 'particular';
expect(composer.canSave.value).toBe(false);
});
it('false ao criar recorrência pra paciente Arquivado', () => {
const items = ref([{ id: 'i-1' }]);
const { composer } = setup(
{ commitmentOptions: [SESSION_COMMITMENT] },
{ commitmentItems: items }
);
ready(composer);
composer.form.value.commitment_id = 'c-session';
composer.form.value.paciente_id = 'p-1';
composer.form.value.paciente_status = 'Arquivado';
composer.billingType.value = 'particular';
composer.recorrenciaType.value = 'semanal';
expect(composer.canSave.value).toBe(false);
});
it('true em billing convenio sem precisar de itens', () => {
const items = ref([]);
const { composer } = setup(
{ commitmentOptions: [SESSION_COMMITMENT] },
{ commitmentItems: items }
);
ready(composer);
composer.form.value.commitment_id = 'c-session';
composer.form.value.paciente_id = 'p-1';
composer.billingType.value = 'convenio';
expect(composer.canSave.value).toBe(true);
});
});
describe('timeConflict (pré-check antes do PATCH)', () => {
function readyForm(composer) {
composer.form.value.dia = new Date('2026-05-15T00:00:00');
composer.form.value.startTime = '14:00';
composer.form.value.duracaoMin = 50;
}
it('null quando form ainda incompleto', () => {
const { composer } = setup();
composer.form.value.dia = null;
expect(composer.timeConflict.value).toBe(null);
});
it('null sem allEvents', () => {
const { composer } = setup({ allEvents: [] });
readyForm(composer);
expect(composer.timeConflict.value).toBe(null);
});
it('detecta overlap com evento existente', () => {
const { composer } = setup({
allEvents: [{
id: 'evt-x',
inicio_em: '2026-05-15T14:30:00',
fim_em: '2026-05-15T15:20:00',
paciente_nome: 'Maria'
}]
});
readyForm(composer);
expect(composer.timeConflict.value).toMatch(/Maria/);
});
it('NÃO detecta overlap com o próprio evento (form.id === evt.id)', () => {
const { composer } = setup({
allEvents: [{
id: 'self',
inicio_em: '2026-05-15T14:00:00',
fim_em: '2026-05-15T14:50:00'
}]
});
readyForm(composer);
composer.form.value.id = 'self';
expect(composer.timeConflict.value).toBe(null);
});
it('null quando evento adjacente (sem overlap real)', () => {
const { composer } = setup({
allEvents: [{
id: 'evt-x',
inicio_em: '2026-05-15T15:00:00',
fim_em: '2026-05-15T15:50:00'
}]
});
readyForm(composer);
composer.form.value.duracaoMin = 50; // 14:0014:50
expect(composer.timeConflict.value).toBe(null);
});
it('detecta sobreposição com pausa do dia', () => {
const { composer } = setup({
pausasSemanais: [{
dia_semana: 5, // sexta (2026-05-15)
hora_inicio: '14:30',
hora_fim: '15:00'
}]
});
readyForm(composer);
expect(composer.timeConflict.value).toMatch(/pausa/i);
});
});
describe('totalConflitos / sessoesForaDoPlano', () => {
it('totalConflitos conta ocorrências com folga/feriado/bloqueado/pausa', () => {
const { composer } = setup({
workRules: [
{ dia_semana: 1 }, { dia_semana: 2 }, { dia_semana: 3 },
{ dia_semana: 4 }, { dia_semana: 5 } // seg-sex
],
blockedDates: ['2026-05-22']
});
composer.recorrenciaType.value = 'semanal';
composer.qtdSessoesMode.value = '4';
composer.form.value.dia = new Date('2026-05-15T10:00:00'); // sexta
// 15 (sex ok) - 22 (sex bloqueada) - 29 (sex ok) - 5/jun (sex ok)
expect(composer.totalConflitos.value).toBe(1);
});
it('sessoesForaDoPlano com dataLimiteManual', () => {
const { composer } = setup();
composer.recorrenciaType.value = 'semanal';
composer.qtdSessoesMode.value = '4';
composer.form.value.dia = new Date('2026-05-15T10:00:00');
composer.dataLimiteManual.value = '2026-05-22';
// ocorrências carregam hora 10:00 (de form.dia); limite "2026-05-22"
// vira meia-noite local. Logo 22/5 10:00 > 22/5 00:00 → fora.
// Resultado: 15 (dentro), 22 (fora), 29 (fora), 5/jun (fora) = 3
expect(composer.sessoesForaDoPlano.value).toBe(3);
});
});
describe('headerTitle', () => {
it('"Editar compromisso" quando isEdit', () => {
const { composer } = setup({ eventRow: { id: 'evt-1' } });
expect(composer.headerTitle.value).toBe('Editar compromisso');
});
it('"Novo compromisso — escolha o tipo" no step 1', () => {
const { composer } = setup();
composer.step.value = 1;
expect(composer.headerTitle.value).toMatch(/escolha o tipo/);
});
it('"Novo compromisso" no step 2', () => {
const { composer } = setup();
composer.step.value = 2;
expect(composer.headerTitle.value).toBe('Novo compromisso');
});
});
describe('computedTitulo', () => {
it('usa titulo_custom quando preenchido', () => {
const { composer } = setup();
composer.form.value.titulo_custom = 'Minha custom';
expect(composer.computedTitulo.value).toBe('Minha custom');
});
it('"—" quando não há commitment selecionado (selectedCommitmentName fallback)', () => {
// selectedCommitmentName retorna "—" quando sem commitment, então
// computedTitulo herda esse "—" porque "—" || "Compromisso" → "—".
// Comportamento original do .vue preservado.
const { composer } = setup();
expect(composer.computedTitulo.value).toBe('—');
});
it('combina nome paciente + commitment quando session', () => {
const { composer } = setup({ commitmentOptions: [SESSION_COMMITMENT] });
composer.form.value.commitment_id = 'c-session';
composer.form.value.paciente_nome = 'Ana';
expect(composer.computedTitulo.value).toBe('Ana [Sessão]');
});
});
@@ -0,0 +1,687 @@
/**
* useAgendaEventLifecycle.spec.js — A66 sub-sessão 1C-ii-b
*
* Cobre: generateRuleDates (pura), loadSerieEvents, 4 onPill handlers,
* selectSlot, quick-creates wiring, onSendManualReminder, e os 4 watchers
* (modelValue init, tenant scope, solicitação pendente, online slots).
*
* Mock supabase: dispatcher por tabela. Cada `.from(table)` retorna um
* builder que registra a chain e retorna `_responses[table]` quando
* awaited / .maybeSingle() / .single().
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ref, computed, nextTick } from 'vue';
// ── Mocks supabase ────────────────────────────────────────────
const _responses = {}; // { 'patients': { data, error }, ... }
const _calls = []; // log das chamadas .from()
const _functionsInvoke = vi.fn(); // pra functions.invoke
function setResponse(table, payload) {
_responses[table] = payload;
}
function clearMocks() {
for (const k of Object.keys(_responses)) delete _responses[k];
_calls.length = 0;
_functionsInvoke.mockReset();
}
function makeBuilder(table) {
const log = { table, ops: [] };
_calls.push(log);
const result = () => _responses[table] ?? { data: null, error: null };
const b = {
select: (...a) => { log.ops.push(['select', ...a]); return b; },
eq: (...a) => { log.ops.push(['eq', ...a]); return b; },
is: (...a) => { log.ops.push(['is', ...a]); return b; },
order: (...a) => { log.ops.push(['order', ...a]); return b; },
limit: (...a) => { log.ops.push(['limit', ...a]); return b; },
maybeSingle: () => Promise.resolve(result()),
single: () => Promise.resolve(result()),
then: (resolve) => resolve(result())
};
return b;
}
vi.mock('@/lib/supabase/client', () => ({
supabase: {
from: (table) => makeBuilder(table),
functions: { invoke: (...args) => _functionsInvoke(...args) }
}
}));
const lifecycleModule = await import('../useAgendaEventLifecycle.js');
const { useAgendaEventLifecycle, generateRuleDates } = lifecycleModule;
// ── Helpers de fixture ────────────────────────────────────────
function makeComposer(overrides = {}) {
const form = ref({
id: null,
commitment_id: null,
paciente_id: null,
paciente_nome: '',
paciente_avatar: '',
insurance_plan_id: null,
insurance_plan_service_id: null,
insurance_value: null,
insurance_guide_number: null,
dia: null,
startTime: '',
modalidade: 'presencial',
...(overrides.formExtra || {})
});
return {
form,
visible: ref(true),
isEdit: ref(false),
hasSerie: ref(false),
isSessionEvent: ref(true),
requiresPatient: ref(true),
billingType: ref('particular'),
recorrenciaType: ref('avulsa'),
diasSelecionados: ref([]),
dataLimiteManual: ref(null),
qtdSessoesMode: ref('4'),
qtdSessoesCustom: ref(12),
editScope: ref('somente_este'),
step: ref(1),
startTimeDate: ref(null),
resetForm: vi.fn(() => ({
id: null,
commitment_id: null,
paciente_id: null,
paciente_nome: '',
paciente_avatar: '',
insurance_plan_id: null,
insurance_plan_service_id: null,
insurance_value: null,
insurance_guide_number: null,
dia: null,
startTime: '',
modalidade: 'presencial'
})),
...overrides
};
}
function makeActions() {
return {
_skipStatusWatch: ref(false),
_restoringConvenio: ref(false),
samePatientConflict: ref(null)
};
}
function makePickerBilling() {
return {
ensureServicesLoaded: vi.fn().mockResolvedValue(),
_loadCommitmentItemsForEvent: vi.fn().mockResolvedValue(),
clearPatientsCache: vi.fn(),
loadPatients: vi.fn().mockResolvedValue(),
addItem: vi.fn().mockResolvedValue()
};
}
function makeConfirm() {
return {
require: vi.fn((opts) => {
// por padrão, dispara accept imediatamente
if (typeof opts.accept === 'function') return opts.accept();
})
};
}
function makeToast() {
return { add: vi.fn() };
}
function setup(overrides = {}, propsOverrides = {}) {
const composer = overrides.composer ?? makeComposer();
const actions = overrides.actions ?? makeActions();
const pickerBilling = overrides.pickerBilling ?? makePickerBilling();
const commitmentItems = overrides.commitmentItems ?? ref([]);
const serieEvents = overrides.serieEvents ?? ref([]);
const servicePickerSel = overrides.servicePickerSel ?? ref(null);
const selectedPlanService = overrides.selectedPlanService ?? ref(null);
const serieValorMode = overrides.serieValorMode ?? ref('multiplicar');
const services = overrides.services ?? ref([]);
const loadServices = overrides.loadServices ?? vi.fn().mockResolvedValue();
const loadInsurancePlans = overrides.loadInsurancePlans ?? vi.fn().mockResolvedValue();
const props = ref({
modelValue: false,
eventRow: null,
ownerId: 'owner-1',
tenantId: 'tenant-1',
planOwnerId: '',
presetCommitmentId: null,
restrictPatientsToOwner: false,
patientScopeOwnerId: null,
...propsOverrides
});
const confirm = overrides.confirm ?? makeConfirm();
const toast = overrides.toast ?? makeToast();
const emit = vi.fn();
const result = useAgendaEventLifecycle({
composer,
actions,
pickerBilling,
commitmentItems,
serieEvents,
servicePickerSel,
selectedPlanService,
serieValorMode,
services,
loadServices,
loadInsurancePlans,
// Vue não aceita `props` reativo aqui — passa o objeto direto.
// Pra simular mudança, modificamos `props.value.X` antes do watcher rodar:
// o `propsRefProxy` abaixo faz o lifecycle ver props como objeto plano,
// mas testes que precisam reatividade no watcher de modelValue usam
// props.value diretamente.
props: new Proxy({}, {
get(_, k) { return props.value[k]; },
set(_, k, v) { props.value[k] = v; return true; }
}),
emit,
confirm,
toast
});
return {
composer, actions, pickerBilling, commitmentItems, serieEvents,
servicePickerSel, selectedPlanService, serieValorMode, services,
loadServices, loadInsurancePlans, propsRef: props, confirm, toast, emit,
...result
};
}
beforeEach(() => {
clearMocks();
});
// ════════════════════════════════════════════════════════════════════
describe('generateRuleDates', () => {
it('sem start_date → []', () => {
expect(generateRuleDates({ weekdays: [1] })).toEqual([]);
});
it('sem weekdays → []', () => {
expect(generateRuleDates({ start_date: '2026-05-04' })).toEqual([]);
});
it('regra null/undefined → []', () => {
expect(generateRuleDates(null)).toEqual([]);
expect(generateRuleDates(undefined)).toEqual([]);
});
it('weekly interval=1 com max_occurrences=3', () => {
// 2026-05-04 é uma segunda-feira (weekday=1)
const dates = generateRuleDates({
type: 'weekly',
interval: 1,
weekdays: [1],
start_date: '2026-05-04',
max_occurrences: 3
});
expect(dates).toEqual(['2026-05-04', '2026-05-11', '2026-05-18']);
});
it('quinzenal interval=2 (gap de 14 dias)', () => {
const dates = generateRuleDates({
type: 'biweekly',
interval: 2,
weekdays: [1],
start_date: '2026-05-04',
max_occurrences: 3
});
expect(dates).toEqual(['2026-05-04', '2026-05-18', '2026-06-01']);
});
it('custom_weekdays — só inclui dias que casam', () => {
// Ter (2) e Qui (4), 4 ocorrências
const dates = generateRuleDates({
type: 'custom_weekdays',
weekdays: [2, 4],
start_date: '2026-05-04',
max_occurrences: 4
});
// Ter 5/5, Qui 7/5, Ter 12/5, Qui 14/5
expect(dates).toEqual(['2026-05-05', '2026-05-07', '2026-05-12', '2026-05-14']);
});
it('respeita end_date', () => {
const dates = generateRuleDates({
type: 'weekly',
interval: 1,
weekdays: [1],
start_date: '2026-05-04',
end_date: '2026-05-15',
max_occurrences: 365
});
expect(dates).toEqual(['2026-05-04', '2026-05-11']);
});
it('clamp max_occurrences a 365', () => {
const dates = generateRuleDates({
type: 'weekly',
interval: 1,
weekdays: [1],
start_date: '2026-01-05',
max_occurrences: 9999
});
expect(dates.length).toBeLessThanOrEqual(365);
});
});
// ════════════════════════════════════════════════════════════════════
describe('loadSerieEvents', () => {
it('sem recurrence_id/serie_id → zera serieEvents', async () => {
const { loadSerieEvents, serieEvents } = setup();
serieEvents.value = [{ id: 'old' }];
await loadSerieEvents();
expect(serieEvents.value).toEqual([]);
});
it('com recurrence_id: gera lista de virtuals + materializa reais', async () => {
const { loadSerieEvents, serieEvents } = setup({}, { eventRow: { recurrence_id: 'rec-1' } });
setResponse('recurrence_rules', {
data: {
id: 'rec-1', type: 'weekly', interval: 1, weekdays: [1],
start_date: '2026-05-04', max_occurrences: 2,
start_time: '14:00:00', duration_min: 50
},
error: null
});
setResponse('recurrence_exceptions', { data: [], error: null });
setResponse('agenda_eventos', {
data: [{ id: 'real-1', inicio_em: '2026-05-04T14:00:00', fim_em: '2026-05-04T14:50:00', status: 'realizado', recurrence_date: '2026-05-04' }],
error: null
});
await loadSerieEvents();
expect(serieEvents.value).toHaveLength(2);
// primeira é a real (status='realizado', _is_virtual=false)
const first = serieEvents.value.find((e) => e.recurrence_date === '2026-05-04');
expect(first.id).toBe('real-1');
expect(first._is_virtual).toBe(false);
expect(first._status).toBe('realizado');
// segunda é virtual
const second = serieEvents.value.find((e) => e.recurrence_date === '2026-05-11');
expect(second.id).toBeNull();
expect(second._is_virtual).toBe(true);
expect(second._status).toBe('agendado');
});
it('exception cancel_session vira _cancelled=true', async () => {
const { loadSerieEvents, serieEvents } = setup({}, { eventRow: { recurrence_id: 'rec-1' } });
setResponse('recurrence_rules', {
data: { id: 'rec-1', type: 'weekly', interval: 1, weekdays: [1], start_date: '2026-05-04', max_occurrences: 1, start_time: '10:00:00', duration_min: 50 },
error: null
});
setResponse('recurrence_exceptions', {
data: [{ original_date: '2026-05-04', type: 'cancel_session', reason: 'paciente desmarcou' }],
error: null
});
setResponse('agenda_eventos', { data: [], error: null });
await loadSerieEvents();
expect(serieEvents.value[0]._cancelled).toBe(true);
expect(serieEvents.value[0]._status).toBe('cancelado');
expect(serieEvents.value[0]._reason).toBe('paciente desmarcou');
});
it('engole erro do supabase e zera lista', async () => {
const { loadSerieEvents, serieEvents } = setup({}, { eventRow: { recurrence_id: 'rec-1' } });
setResponse('recurrence_rules', { data: null, error: new Error('boom') });
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
await loadSerieEvents();
expect(serieEvents.value).toEqual([]);
errSpy.mockRestore();
});
});
// ════════════════════════════════════════════════════════════════════
describe('onPill handlers', () => {
it('onPillEditClick: emit editSeriesOccurrence com payload', () => {
const { onPillEditClick, emit } = setup();
onPillEditClick({ id: 'e-1', recurrence_date: '2026-05-04', inicio_em: 'X', fim_em: 'Y', _is_virtual: true });
expect(emit).toHaveBeenCalledWith('editSeriesOccurrence', {
id: 'e-1',
recurrence_date: '2026-05-04',
inicio_em: 'X',
fim_em: 'Y',
is_virtual: true
});
});
it('onPillStatusChange: emit updateSeriesEvent', () => {
const { onPillStatusChange, emit } = setup();
onPillStatusChange({ id: 'e-1', _status: 'realizado', recurrence_date: '2026-05-04', inicio_em: 'X', fim_em: 'Y', _is_virtual: false });
expect(emit).toHaveBeenCalledWith('updateSeriesEvent', expect.objectContaining({
id: 'e-1', status: 'realizado', is_virtual: false
}));
});
it('onPillStatusChange virtual agenda recarregamento (setTimeout)', () => {
vi.useFakeTimers();
const { onPillStatusChange, serieEvents } = setup({}, { eventRow: { recurrence_id: 'rec-1' } });
setResponse('recurrence_rules', { data: null, error: null });
setResponse('recurrence_exceptions', { data: [], error: null });
setResponse('agenda_eventos', { data: [], error: null });
onPillStatusChange({ id: null, _status: 'agendado', _is_virtual: true });
vi.advanceTimersByTime(700);
// Triggered loadSerieEvents — não vamos esperar await aqui;
// basta saber que não quebrou.
vi.useRealTimers();
expect(serieEvents.value).toBeDefined();
});
it('onPillDelete (somente_este): confirm + emit delete', () => {
const confirm = makeConfirm();
const { onPillDelete, emit } = setup({ confirm }, { eventRow: { recurrence_id: 'rec-1', serie_id: 'ser-1' } });
onPillDelete({ id: 'e-1', recurrence_date: '2026-05-04', inicio_em: '2026-05-04T14:00:00' }, 'somente_este');
expect(confirm.require).toHaveBeenCalled();
expect(emit).toHaveBeenCalledWith('delete', expect.objectContaining({
id: 'e-1',
editMode: 'somente_este',
recurrence_id: 'rec-1',
original_date: '2026-05-04',
serie_id: 'ser-1'
}));
});
it('onPillDelete (todos): label header diferente', () => {
const confirm = makeConfirm();
const { onPillDelete } = setup({ confirm });
onPillDelete({ id: 'e-1', inicio_em: '2026-05-04T14:00:00' }, 'todos');
const callArgs = confirm.require.mock.calls[0][0];
expect(callArgs.header).toBe('Encerrar toda a série');
expect(callArgs.icon).toBe('pi pi-trash');
expect(callArgs.acceptLabel).toBe('Sim, encerrar série');
});
});
// ════════════════════════════════════════════════════════════════════
describe('selectSlot', () => {
it('atualiza startTimeDate com a hora', () => {
const { selectSlot, composer } = setup();
selectSlot('14:30');
const d = composer.startTimeDate.value;
expect(d).toBeInstanceOf(Date);
expect(d.getHours()).toBe(14);
expect(d.getMinutes()).toBe(30);
});
});
// ════════════════════════════════════════════════════════════════════
describe('quick-creates', () => {
it('openServiceQuickCreate seta serviceQuickDlgOpen=true', () => {
const { openServiceQuickCreate, serviceQuickDlgOpen } = setup();
expect(serviceQuickDlgOpen.value).toBe(false);
openServiceQuickCreate();
expect(serviceQuickDlgOpen.value).toBe(true);
});
it('onServiceCreated chama loadServices e addItem com o svc fresco se encontrado', async () => {
const services = ref([{ id: 's-1', name: 'Sessão Atualizada', price: 200 }]);
const pickerBilling = makePickerBilling();
const { onServiceCreated, loadServices } = setup({ services, pickerBilling });
await onServiceCreated({ id: 's-1', name: 'Sessão', price: 100 });
expect(loadServices).toHaveBeenCalledWith('owner-1');
expect(pickerBilling.addItem).toHaveBeenCalledWith({ id: 's-1', name: 'Sessão Atualizada', price: 200 });
});
it('onServiceCreated com svc não na lista usa o param', async () => {
const services = ref([]);
const pickerBilling = makePickerBilling();
const { onServiceCreated } = setup({ services, pickerBilling });
await onServiceCreated({ id: 's-1', name: 'Param', price: 50 });
expect(pickerBilling.addItem).toHaveBeenCalledWith({ id: 's-1', name: 'Param', price: 50 });
});
it('onServiceCreated sem svc.id → não chama addItem', async () => {
const pickerBilling = makePickerBilling();
const { onServiceCreated } = setup({ pickerBilling });
await onServiceCreated(null);
expect(pickerBilling.addItem).not.toHaveBeenCalled();
});
it('openInsuranceQuickCreate seta flag', () => {
const { openInsuranceQuickCreate, insuranceQuickDlgOpen } = setup();
expect(insuranceQuickDlgOpen.value).toBe(false);
openInsuranceQuickCreate();
expect(insuranceQuickDlgOpen.value).toBe(true);
});
it('onInsuranceCreated chama loadInsurancePlans e seta plan_id', async () => {
const { onInsuranceCreated, loadInsurancePlans, composer } = setup({}, { planOwnerId: 'planowner-1' });
await onInsuranceCreated({ id: 'plan-99' });
expect(loadInsurancePlans).toHaveBeenCalledWith('planowner-1');
expect(composer.form.value.insurance_plan_id).toBe('plan-99');
});
it('onInsuranceCreated fallback ownerId quando não há planOwnerId', async () => {
const { onInsuranceCreated, loadInsurancePlans } = setup();
await onInsuranceCreated({ id: 'plan-1' });
expect(loadInsurancePlans).toHaveBeenCalledWith('owner-1');
});
});
// ════════════════════════════════════════════════════════════════════
describe('onSendManualReminder', () => {
it('no-op se form.id é null', async () => {
const { onSendManualReminder, confirm } = setup();
await onSendManualReminder();
expect(confirm.require).not.toHaveBeenCalled();
});
it('sucesso: chama functions.invoke + toast success', async () => {
const composer = makeComposer({ formExtra: { id: 'evt-1', paciente_nome: 'Marina' } });
_functionsInvoke.mockResolvedValueOnce({ data: { ok: true, to: '+5516988887777' }, error: null });
const { onSendManualReminder, toast, sendingReminder } = setup({ composer });
await onSendManualReminder();
expect(_functionsInvoke).toHaveBeenCalledWith('send-session-reminder-manual', { body: { event_id: 'evt-1' } });
expect(toast.add).toHaveBeenCalledWith(expect.objectContaining({ severity: 'success' }));
expect(sendingReminder.value).toBe(false);
});
it('error no_phone vira "Paciente sem telefone cadastrado."', async () => {
const composer = makeComposer({ formExtra: { id: 'evt-1' } });
_functionsInvoke.mockResolvedValueOnce({ data: { ok: false, error: 'no_phone' }, error: null });
const { onSendManualReminder, toast } = setup({ composer });
await onSendManualReminder();
expect(toast.add).toHaveBeenCalledWith(expect.objectContaining({
severity: 'error',
detail: 'Paciente sem telefone cadastrado.'
}));
});
it('error template_not_found tem mensagem amigável', async () => {
const composer = makeComposer({ formExtra: { id: 'evt-1' } });
_functionsInvoke.mockResolvedValueOnce({ data: { ok: false, error: 'template_not_found' }, error: null });
const { onSendManualReminder, toast } = setup({ composer });
await onSendManualReminder();
expect(toast.add).toHaveBeenCalledWith(expect.objectContaining({
detail: expect.stringContaining('lembrete_sessao')
}));
});
it('error send_failed_X também é amigável', async () => {
const composer = makeComposer({ formExtra: { id: 'evt-1' } });
_functionsInvoke.mockResolvedValueOnce({ data: { ok: false, error: 'send_failed_timeout' }, error: null });
const { onSendManualReminder, toast } = setup({ composer });
await onSendManualReminder();
expect(toast.add).toHaveBeenCalledWith(expect.objectContaining({
detail: 'Não conseguimos enviar. Verifique a conexão do WhatsApp.'
}));
});
});
// ════════════════════════════════════════════════════════════════════
describe('watchers — tenant scope', () => {
it('quando tenantId muda e dialog visível, recarrega patients', async () => {
const composer = makeComposer();
const pickerBilling = makePickerBilling();
const { propsRef } = setup({ composer, pickerBilling });
pickerBilling.clearPatientsCache.mockClear();
pickerBilling.loadPatients.mockClear();
propsRef.value.tenantId = 'tenant-2';
await nextTick();
expect(pickerBilling.clearPatientsCache).toHaveBeenCalled();
expect(pickerBilling.loadPatients).toHaveBeenCalledWith(true);
});
it('não recarrega se dialog não está visível', async () => {
const composer = makeComposer();
composer.visible.value = false;
const pickerBilling = makePickerBilling();
const { propsRef } = setup({ composer, pickerBilling });
pickerBilling.clearPatientsCache.mockClear();
propsRef.value.tenantId = 'tenant-2';
await nextTick();
expect(pickerBilling.clearPatientsCache).not.toHaveBeenCalled();
});
});
describe('watchers — solicitação pendente', () => {
it('busca solicitação quando dia + startTime estão setados (não-edit)', async () => {
const composer = makeComposer({ formExtra: { dia: new Date('2026-05-04T12:00:00'), startTime: '14:00' } });
setResponse('agendador_solicitacoes', { data: { id: 'sol-1', paciente_nome: 'João' }, error: null });
const { solicitacaoPendente } = setup({ composer });
// dispara watcher: já estava setado mas não imediato — mudamos pra forçar
composer.form.value.startTime = '15:00';
await nextTick();
await new Promise((r) => setTimeout(r, 0));
expect(solicitacaoPendente.value).toEqual({ id: 'sol-1', paciente_nome: 'João' });
});
it('em modo edit não busca', async () => {
const composer = makeComposer({ formExtra: { dia: new Date('2026-05-04T12:00:00'), startTime: '14:00' } });
composer.isEdit.value = true;
const { solicitacaoPendente } = setup({ composer });
composer.form.value.startTime = '15:00';
await nextTick();
await new Promise((r) => setTimeout(r, 0));
expect(solicitacaoPendente.value).toBeNull();
});
it('sem ownerId não busca', async () => {
const composer = makeComposer({ formExtra: { dia: new Date('2026-05-04T12:00:00'), startTime: '14:00' } });
const { solicitacaoPendente } = setup({ composer }, { ownerId: '' });
composer.form.value.startTime = '15:00';
await nextTick();
await new Promise((r) => setTimeout(r, 0));
expect(solicitacaoPendente.value).toBeNull();
});
});
describe('watchers — online slots', () => {
it('modalidade=online + dia + ownerId → carrega slots', async () => {
const composer = makeComposer({ formExtra: { dia: new Date('2026-05-04T12:00:00'), modalidade: 'online' } });
setResponse('agenda_online_slots', {
data: [{ time: '14:00:00' }, { time: '15:00:00' }],
error: null
});
const { onlineSlots } = setup({ composer });
// immediate=true já disparou
await new Promise((r) => setTimeout(r, 0));
expect(onlineSlots.value).toEqual([{ hhmm: '14:00' }, { hhmm: '15:00' }]);
});
it('modalidade=presencial → zera slots', async () => {
const composer = makeComposer({ formExtra: { dia: new Date('2026-05-04T12:00:00'), modalidade: 'presencial' } });
const { onlineSlots } = setup({ composer });
await new Promise((r) => setTimeout(r, 0));
expect(onlineSlots.value).toEqual([]);
});
it('engole erro do supabase e zera lista', async () => {
const composer = makeComposer({ formExtra: { dia: new Date('2026-05-04T12:00:00'), modalidade: 'online' } });
// Não setamos response, mas vamos mockar o erro substituindo o builder
// — usa simply o caminho try/catch via reject do `then`
// Para esse caso, deixamos sem setResponse e ele retorna { data: null, error: null }
// (sem dispara erro). Vou setar erro explicitamente:
setResponse('agenda_online_slots', { data: null, error: new Error('boom') });
const { onlineSlots } = setup({ composer });
await new Promise((r) => setTimeout(r, 0));
// Como erro vem em `error` mas o código não checa explicitamente — só faz
// map sobre data || []. Logo, vai ser [] mesmo.
expect(onlineSlots.value).toEqual([]);
});
});
describe('watcher — modelValue init', () => {
it('ao abrir, reseta refs e chama orchestration', async () => {
const composer = makeComposer();
const actions = makeActions();
const pickerBilling = makePickerBilling();
const loadInsurancePlans = vi.fn().mockResolvedValue();
const serieValorMode = ref('dividir');
const { propsRef } = setup({ composer, actions, pickerBilling, serieValorMode, loadInsurancePlans });
propsRef.value.modelValue = true;
// deixa watcher async correr
await nextTick();
await new Promise((r) => setTimeout(r, 0));
await nextTick();
// resets aplicados
expect(composer.recorrenciaType.value).toBe('avulsa');
expect(composer.diasSelecionados.value).toEqual([]);
expect(composer.qtdSessoesMode.value).toBe('4');
expect(composer.qtdSessoesCustom.value).toBe(12);
expect(composer.editScope.value).toBe('somente_este');
expect(serieValorMode.value).toBe('multiplicar');
// step 1 (não-edit, sem preset)
expect(composer.step.value).toBe(1);
// ensureServicesLoaded chamado
expect(pickerBilling.ensureServicesLoaded).toHaveBeenCalled();
// billingType default novo evento
expect(composer.billingType.value).toBe('particular');
// _restoringConvenio resetado
expect(actions._restoringConvenio.value).toBe(false);
});
it('com presetCommitmentId vai pra step=2 + commitment_id setado', async () => {
const composer = makeComposer();
const { propsRef } = setup({ composer });
propsRef.value.presetCommitmentId = 'commit-99';
propsRef.value.modelValue = true;
await nextTick();
await new Promise((r) => setTimeout(r, 0));
await nextTick();
expect(composer.form.value.commitment_id).toBe('commit-99');
expect(composer.step.value).toBe(2);
});
it('em modo edit vai pra step=2 e chama _loadCommitmentItemsForEvent quando há id', async () => {
const composer = makeComposer({ formExtra: { id: 'evt-7' } });
composer.isEdit.value = true;
composer.resetForm = vi.fn(() => ({ ...composer.form.value })); // mantém id
const pickerBilling = makePickerBilling();
const { propsRef } = setup({ composer, pickerBilling });
propsRef.value.modelValue = true;
await nextTick();
await new Promise((r) => setTimeout(r, 0));
await nextTick();
expect(composer.step.value).toBe(2);
expect(pickerBilling._loadCommitmentItemsForEvent).toHaveBeenCalledWith('evt-7');
});
it('hasSerie=true dispara loadSerieEvents (sem rid → fica []) ', async () => {
const composer = makeComposer();
composer.hasSerie.value = true;
const { propsRef, serieEvents } = setup({ composer });
propsRef.value.modelValue = true;
await nextTick();
await new Promise((r) => setTimeout(r, 0));
// sem eventRow.recurrence_id, loadSerieEvents zera
expect(serieEvents.value).toEqual([]);
});
it('quando close (modelValue=false) não faz nada', async () => {
const composer = makeComposer();
composer.recorrenciaType.value = 'semanal';
const { propsRef } = setup({ composer });
propsRef.value.modelValue = false;
await nextTick();
// recorrenciaType permanece como estava
expect(composer.recorrenciaType.value).toBe('semanal');
});
});
@@ -0,0 +1,611 @@
/**
* useAgendaEventPickerBilling.spec.js — A66 sub-sessão 1C-ii-a
*
* Cobre handlers de patient picker + billing items + 2 watchers
* (form.commitment_id, form.insurance_plan_id).
*
* Mock estratégia: monta um composer fake + actions fake com refs/computeds
* mínimos. Mock supabase.from('patients') pra loadPatients.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { ref, computed } from 'vue';
// ── Mocks ─────────────────────────────────────────────────────
let _supabaseSelectArgs = null;
const _patientsResult = { data: [], error: null };
function makeQ() {
// O loadPatients faz: from().select().order().limit() e DEPOIS adiciona
// .eq() condicionalmente, finalizando com `await q`. Pra suportar isso,
// o mock retorna o mesmo `q` em todos os métodos da chain e implementa
// `then` (thenable) pra ser awaitable.
const q = {
select: (...args) => {
_supabaseSelectArgs = args;
return q;
},
eq: () => q,
order: () => q,
limit: () => q,
then: (resolve) => resolve(_patientsResult)
};
return q;
}
vi.mock('@/lib/supabase/client', () => ({
supabase: {
from: () => makeQ()
}
}));
function resetPatientsResult({ data = [], error = null } = {}) {
_patientsResult.data = data;
_patientsResult.error = error;
}
const { useAgendaEventPickerBilling } = await import('../useAgendaEventPickerBilling.js');
// ── Helpers ───────────────────────────────────────────────────
function makeComposer(overrides = {}) {
const form = ref({
commitment_id: null,
paciente_id: null,
paciente_nome: '',
paciente_avatar: '',
extra_fields: {},
price: null,
insurance_plan_id: null,
insurance_plan_service_id: null,
insurance_value: null,
insurance_guide_number: null,
duracaoMin: 50,
...(overrides.formExtra || {})
});
return {
form,
billingType: ref('particular'),
isEdit: ref(false),
visible: ref(true),
step: ref(1),
requiresPatient: ref(true),
allowBack: ref(true),
...overrides
};
}
function makeActions() {
return {
_restoringConvenio: ref(false),
samePatientConflict: ref(null)
};
}
function setup(overrides = {}, propsOverrides = {}) {
_supabaseSelectArgs = null;
// NÃO resetamos _patientsResult aqui pra permitir que o teste pré-popule.
// Cada `it` chama resetPatientsResult() explicitamente quando precisa.
const composer = overrides.composer ?? makeComposer();
const actions = overrides.actions ?? makeActions();
const commitmentItems = overrides.commitmentItems ?? ref([]);
const servicePickerSel = overrides.servicePickerSel ?? ref(null);
const selectedPlanService = overrides.selectedPlanService ?? ref(null);
const services = overrides.services ?? ref([]);
const loadServices = vi.fn().mockResolvedValue();
const getDefaultPrice = overrides.getDefaultPrice ?? vi.fn(() => null);
const planServices = overrides.planServices ?? computed(() => []);
const loadActiveDiscount = overrides.loadActiveDiscount ?? vi.fn().mockResolvedValue(null);
const _csLoadItems = overrides._csLoadItems ?? vi.fn().mockResolvedValue([]);
const _csLoadItemsOrTemplate = overrides._csLoadItemsOrTemplate ?? vi.fn().mockResolvedValue([]);
const isDynamic = overrides.isDynamic ?? computed(() => false);
const props = {
ownerId: 'owner-1',
tenantId: 'tenant-1',
eventRow: null,
agendaSettings: { session_duration_min: 50 },
restrictPatientsToOwner: false,
patientScopeOwnerId: null,
newPatientRoute: '',
...propsOverrides
};
const result = useAgendaEventPickerBilling({
composer,
actions,
commitmentItems,
servicePickerSel,
selectedPlanService,
services,
loadServices,
getDefaultPrice,
planServices,
loadActiveDiscount,
_csLoadItems,
_csLoadItemsOrTemplate,
isDynamic,
props
});
return {
composer,
actions,
commitmentItems,
servicePickerSel,
selectedPlanService,
services,
loadServices,
getDefaultPrice,
planServices,
loadActiveDiscount,
_csLoadItems,
_csLoadItemsOrTemplate,
isDynamic,
props,
...result
};
}
// ════════════════════════════════════════════════════════════════════
describe('addItem', () => {
it('no-op sem service.id', async () => {
const { commitmentItems, addItem } = setup();
await addItem(null);
await addItem({});
expect(commitmentItems.value).toEqual([]);
});
it('adiciona item novo com final_price calculado', async () => {
const { commitmentItems, addItem } = setup();
await addItem({ id: 's-1', name: 'Sessão', price: 100 });
expect(commitmentItems.value).toHaveLength(1);
expect(commitmentItems.value[0]).toMatchObject({
service_id: 's-1',
service_name: 'Sessão',
quantity: 1,
unit_price: 100,
discount_pct: 0,
discount_flat: 0,
final_price: 100
});
});
it('incrementa quantity quando service_id já existe', async () => {
const { commitmentItems, addItem } = setup();
await addItem({ id: 's-1', name: 'Sessão', price: 100 });
await addItem({ id: 's-1', name: 'Sessão', price: 100 });
await addItem({ id: 's-1', name: 'Sessão', price: 100 });
expect(commitmentItems.value).toHaveLength(1);
expect(commitmentItems.value[0].quantity).toBe(3);
expect(commitmentItems.value[0].final_price).toBe(300);
});
it('aplica desconto ativo do paciente', async () => {
const composer = makeComposer({ formExtra: { paciente_id: 'p-1' } });
const loadActiveDiscount = vi.fn().mockResolvedValue({ discount_pct: 10, discount_flat: 5 });
const { commitmentItems, addItem } = setup({ composer, loadActiveDiscount });
await addItem({ id: 's-1', name: 'X', price: 100 });
expect(loadActiveDiscount).toHaveBeenCalledWith('owner-1', 'p-1');
// 100 - 10% = 90 - 5 = 85
expect(commitmentItems.value[0].final_price).toBe(85);
});
it('sem patient_id NÃO chama loadActiveDiscount', async () => {
const { addItem, loadActiveDiscount } = setup();
await addItem({ id: 's-1', name: 'X', price: 100 });
expect(loadActiveDiscount).not.toHaveBeenCalled();
});
});
describe('removeItem', () => {
it('remove item por índice', () => {
const items = ref([
{ service_id: 'a', quantity: 1, final_price: 100 },
{ service_id: 'b', quantity: 1, final_price: 200 }
]);
const { removeItem, commitmentItems } = setup({ commitmentItems: items });
removeItem(0);
expect(commitmentItems.value).toHaveLength(1);
expect(commitmentItems.value[0].service_id).toBe('b');
});
it('lista vazia em modo dynamic restaura duração padrão', () => {
const composer = makeComposer({ formExtra: { duracaoMin: 30 } });
const isDynamic = computed(() => true);
const items = ref([{ service_id: 'a', quantity: 1, final_price: 100 }]);
const { removeItem } = setup({ composer, isDynamic, commitmentItems: items });
removeItem(0);
expect(composer.form.value.duracaoMin).toBe(50); // session_duration_min default
});
it('NÃO restaura duração se !isDynamic', () => {
const composer = makeComposer({ formExtra: { duracaoMin: 30 } });
const items = ref([{ service_id: 'a', quantity: 1, final_price: 100 }]);
const { removeItem } = setup({ composer, commitmentItems: items });
removeItem(0);
expect(composer.form.value.duracaoMin).toBe(30);
});
});
describe('onItemChange', () => {
it('recalcula final_price baseado em quantity/discounts', () => {
const { onItemChange } = setup();
const item = { unit_price: 100, quantity: 2, discount_pct: 10, discount_flat: 0, final_price: 0 };
onItemChange(item);
expect(item.final_price).toBe(180); // 200 - 10% = 180
});
});
describe('onProcedureSelect', () => {
it('seta plan_service_id e atualiza insurance_value', () => {
const planServices = computed(() => [{ id: 'ps-1', value: 250.5 }]);
const composer = makeComposer();
const { onProcedureSelect } = setup({ composer, planServices });
onProcedureSelect('ps-1');
expect(composer.form.value.insurance_plan_service_id).toBe('ps-1');
expect(composer.form.value.insurance_value).toBe(250.5);
});
it('null limpa insurance_value', () => {
const composer = makeComposer({ formExtra: { insurance_value: 100 } });
const { onProcedureSelect } = setup({ composer });
onProcedureSelect(null);
expect(composer.form.value.insurance_plan_service_id).toBe(null);
expect(composer.form.value.insurance_value).toBe(null);
});
it('id desconhecido limpa insurance_value', () => {
const planServices = computed(() => [{ id: 'ps-1', value: 250 }]);
const composer = makeComposer({ formExtra: { insurance_value: 100 } });
const { onProcedureSelect } = setup({ composer, planServices });
onProcedureSelect('ps-x');
expect(composer.form.value.insurance_plan_service_id).toBe('ps-x');
expect(composer.form.value.insurance_value).toBe(null);
});
});
describe('selectCommitment', () => {
it('seta commitment_id, reseta extra_fields, vai pra step 2', () => {
const composer = makeComposer();
const { selectCommitment } = setup({ composer });
selectCommitment({ id: 'c-1', name: 'X', fields: [{ key: 'idade' }, { key: 'peso' }] });
expect(composer.form.value.commitment_id).toBe('c-1');
expect(composer.form.value.extra_fields).toEqual({ idade: '', peso: '' });
expect(composer.step.value).toBe(2);
});
it('no-op sem id', () => {
const composer = makeComposer();
const { selectCommitment } = setup({ composer });
composer.step.value = 1;
selectCommitment(null);
selectCommitment({});
expect(composer.step.value).toBe(1);
});
});
describe('goBack', () => {
it('volta pra step 1 e limpa commitment + paciente', () => {
const composer = makeComposer({
formExtra: { commitment_id: 'c-1', paciente_id: 'p-1', paciente_nome: 'Ana' }
});
composer.step.value = 2;
const { goBack } = setup({ composer });
goBack();
expect(composer.step.value).toBe(1);
expect(composer.form.value.commitment_id).toBe(null);
expect(composer.form.value.paciente_id).toBe(null);
expect(composer.form.value.paciente_nome).toBe('');
});
it('no-op em edição', () => {
const composer = makeComposer();
composer.isEdit.value = true;
composer.step.value = 2;
const { goBack } = setup({ composer });
goBack();
expect(composer.step.value).toBe(2);
});
it('no-op com !allowBack', () => {
const composer = makeComposer();
composer.allowBack.value = false;
composer.step.value = 2;
const { goBack } = setup({ composer });
goBack();
expect(composer.step.value).toBe(2);
});
});
describe('selectPaciente / clearPaciente', () => {
it('selectPaciente preenche form e fecha picker', () => {
const composer = makeComposer();
const { selectPaciente, pacientePickerOpen } = setup({ composer });
pacientePickerOpen.value = true;
selectPaciente({ id: 'p-1', nome: 'Ana', avatar_url: 'url' });
expect(composer.form.value.paciente_id).toBe('p-1');
expect(composer.form.value.paciente_nome).toBe('Ana');
expect(composer.form.value.paciente_avatar).toBe('url');
expect(pacientePickerOpen.value).toBe(false);
});
it('selectPaciente no-op sem id', () => {
const composer = makeComposer();
const { selectPaciente } = setup({ composer });
selectPaciente(null);
selectPaciente({});
expect(composer.form.value.paciente_id).toBe(null);
});
it('clearPaciente limpa form + samePatientConflict', () => {
const composer = makeComposer({
formExtra: { paciente_id: 'p-1', paciente_nome: 'Ana', paciente_avatar: 'url' }
});
const actions = makeActions();
actions.samePatientConflict.value = { id: 'evt-x' };
const { clearPaciente } = setup({ composer, actions });
clearPaciente();
expect(composer.form.value.paciente_id).toBe(null);
expect(composer.form.value.paciente_nome).toBe('');
expect(actions.samePatientConflict.value).toBe(null);
});
});
describe('openPacientePicker', () => {
it('abre picker e dispara loadPatients', () => {
const composer = makeComposer();
composer.requiresPatient.value = true;
const { openPacientePicker, pacientePickerOpen } = setup({ composer });
openPacientePicker();
expect(pacientePickerOpen.value).toBe(true);
});
it('no-op quando NÃO requiresPatient', () => {
const composer = makeComposer();
composer.requiresPatient.value = false;
const { openPacientePicker, pacientePickerOpen } = setup({ composer });
openPacientePicker();
expect(pacientePickerOpen.value).toBe(false);
});
});
describe('clearPatientsCache', () => {
it('zera patients, search e error', () => {
const { clearPatientsCache, patients, pacienteSearch, pacientesError } = setup();
patients.value = [{ id: 'p-1' }];
pacienteSearch.value = 'foo';
pacientesError.value = 'err';
clearPatientsCache();
expect(patients.value).toEqual([]);
expect(pacienteSearch.value).toBe('');
expect(pacientesError.value).toBe('');
});
});
describe('loadPatients', () => {
it('faz fetch e mapeia resultado', async () => {
const { loadPatients, patients } = setup();
resetPatientsResult({
data: [{ id: 'p-1', nome_completo: 'Ana', email_principal: 'a@x', telefone: '11', status: 'Ativo', avatar_url: 'u' }]
});
await loadPatients(true);
expect(patients.value).toHaveLength(1);
expect(patients.value[0]).toMatchObject({
id: 'p-1',
nome: 'Ana',
email: 'a@x',
telefone: '11'
});
});
it('skip quando já tem cache e !force', async () => {
const { loadPatients, patients } = setup();
patients.value = [{ id: 'cached' }];
await loadPatients(false);
expect(patients.value).toEqual([{ id: 'cached' }]);
});
it('error → setta pacientesError e zera lista', async () => {
const { loadPatients, patients, pacientesError } = setup();
resetPatientsResult({ data: null, error: new Error('boom') });
await loadPatients(true);
expect(patients.value).toEqual([]);
expect(pacientesError.value).toBe('boom');
});
});
describe('applyDefaultPrice', () => {
it('skip em billingType=particular', () => {
const composer = makeComposer();
composer.billingType.value = 'particular';
const getDefaultPrice = vi.fn(() => 100);
const { applyDefaultPrice } = setup({ composer, getDefaultPrice });
applyDefaultPrice();
expect(composer.form.value.price).toBe(null);
});
it('skip em edição', () => {
const composer = makeComposer();
composer.billingType.value = 'gratuito';
composer.isEdit.value = true;
const getDefaultPrice = vi.fn(() => 100);
const { applyDefaultPrice } = setup({ composer, getDefaultPrice });
applyDefaultPrice();
expect(composer.form.value.price).toBe(null);
});
it('aplica em criação + billingType != particular', () => {
const composer = makeComposer();
composer.billingType.value = 'gratuito';
const getDefaultPrice = vi.fn(() => 75);
const { applyDefaultPrice } = setup({ composer, getDefaultPrice });
applyDefaultPrice();
expect(composer.form.value.price).toBe(75);
});
});
describe('Watcher: form.commitment_id (auto-fill price)', () => {
it('dispara em criação + visível: ensureServices + applyDefaultPrice', async () => {
const composer = makeComposer();
// applyDefaultPrice skip se billingType=particular (default); usar 'gratuito'
composer.billingType.value = 'gratuito';
const getDefaultPrice = vi.fn(() => 80);
const { loadServices, composer: c } = setup({ composer, getDefaultPrice });
c.form.value.commitment_id = 'c-1';
await new Promise((r) => setTimeout(r, 0));
expect(loadServices).toHaveBeenCalled();
expect(c.form.value.price).toBe(80);
});
it('NÃO dispara em edição', async () => {
const composer = makeComposer();
composer.isEdit.value = true;
const { loadServices } = setup({ composer });
composer.form.value.commitment_id = 'c-1';
await new Promise((r) => setTimeout(r, 0));
expect(loadServices).not.toHaveBeenCalled();
});
it('NÃO dispara quando dialog fechado (!visible)', async () => {
const composer = makeComposer();
composer.visible.value = false;
const { loadServices } = setup({ composer });
composer.form.value.commitment_id = 'c-1';
await new Promise((r) => setTimeout(r, 0));
expect(loadServices).not.toHaveBeenCalled();
});
});
describe('Watcher: form.insurance_plan_id', () => {
it('seleciona convênio: limpa items + servicePickerSel', async () => {
const composer = makeComposer();
const items = ref([{ service_id: 'a' }]);
const sps = ref('svc-x');
const { commitmentItems, servicePickerSel } = setup({
composer,
commitmentItems: items,
servicePickerSel: sps
});
composer.form.value.insurance_plan_id = 'plan-1';
await new Promise((r) => setTimeout(r, 0));
expect(commitmentItems.value).toEqual([]);
expect(servicePickerSel.value).toBe(null);
});
it('desmarca convênio: limpa insurance_value e guide', async () => {
const composer = makeComposer({
formExtra: { insurance_value: 100, insurance_guide_number: 'X' }
});
const { composer: c } = setup({ composer });
c.form.value.insurance_plan_id = 'plan-1';
await new Promise((r) => setTimeout(r, 0));
c.form.value.insurance_plan_id = null;
await new Promise((r) => setTimeout(r, 0));
expect(c.form.value.insurance_value).toBe(null);
expect(c.form.value.insurance_guide_number).toBe(null);
});
it('ignorado quando _restoringConvenio', async () => {
const composer = makeComposer();
const actions = makeActions();
actions._restoringConvenio.value = true;
const items = ref([{ service_id: 'a' }]);
const { commitmentItems } = setup({
composer,
actions,
commitmentItems: items
});
composer.form.value.insurance_plan_id = 'plan-1';
await new Promise((r) => setTimeout(r, 0));
// Restoração ativa: não toca em commitmentItems
expect(commitmentItems.value).toHaveLength(1);
});
});
describe('_loadCommitmentItemsForEvent', () => {
it('sem eventId nem ruleId: limpa items, billingType=particular', async () => {
const composer = makeComposer();
const items = ref([{ service_id: 'a' }]);
const { _loadCommitmentItemsForEvent, commitmentItems } = setup({
composer,
commitmentItems: items
});
await _loadCommitmentItemsForEvent(null);
expect(commitmentItems.value).toEqual([]);
expect(composer.billingType.value).toBe('particular');
});
it('eventRow com insurance_plan_id: aplica convenio', async () => {
const composer = makeComposer();
const props = {
ownerId: 'owner-1',
tenantId: 't-1',
agendaSettings: { session_duration_min: 50 },
eventRow: {
insurance_plan_id: 'plan-1',
insurance_guide_number: 'G-1',
insurance_value: 200,
insurance_plan_service_id: 'ps-1'
}
};
const { _loadCommitmentItemsForEvent } = setup({ composer }, props);
await _loadCommitmentItemsForEvent('evt-1');
// Espera nextTick interno
await new Promise((r) => setTimeout(r, 10));
expect(composer.billingType.value).toBe('convenio');
expect(composer.form.value.insurance_plan_id).toBe('plan-1');
expect(composer.form.value.insurance_value).toBe(200);
});
it('com items carregados: billingType=particular', async () => {
const composer = makeComposer();
const _csLoadItems = vi.fn().mockResolvedValue([{ service_id: 's-1', final_price: 100 }]);
const { _loadCommitmentItemsForEvent, commitmentItems } = setup({
composer,
_csLoadItems
});
await _loadCommitmentItemsForEvent('evt-1');
expect(commitmentItems.value).toHaveLength(1);
expect(composer.billingType.value).toBe('particular');
});
it('sem items: billingType=gratuito', async () => {
const composer = makeComposer();
const _csLoadItems = vi.fn().mockResolvedValue([]);
const { _loadCommitmentItemsForEvent } = setup({ composer, _csLoadItems });
await _loadCommitmentItemsForEvent('evt-1');
expect(composer.billingType.value).toBe('gratuito');
});
it('error: items=[], billingType=gratuito', async () => {
const composer = makeComposer();
const _csLoadItems = vi.fn().mockRejectedValue(new Error('boom'));
const { _loadCommitmentItemsForEvent, commitmentItems } = setup({
composer,
_csLoadItems
});
await _loadCommitmentItemsForEvent('evt-1');
expect(commitmentItems.value).toEqual([]);
expect(composer.billingType.value).toBe('gratuito');
});
});
describe('ensureServicesLoaded', () => {
it('carrega só uma vez (gate)', async () => {
const { ensureServicesLoaded, loadServices } = setup();
await ensureServicesLoaded();
await ensureServicesLoaded();
await ensureServicesLoaded();
expect(loadServices).toHaveBeenCalledTimes(1);
});
it('skip sem ownerId', async () => {
const { ensureServicesLoaded, loadServices } = setup({}, { ownerId: '' });
await ensureServicesLoaded();
expect(loadServices).not.toHaveBeenCalled();
});
it('resetServicesGate permite re-load', async () => {
const { ensureServicesLoaded, resetServicesGate, loadServices } = setup();
await ensureServicesLoaded();
resetServicesGate();
await ensureServicesLoaded();
expect(loadServices).toHaveBeenCalledTimes(2);
});
});
@@ -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' : '';
}
@@ -0,0 +1,387 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/agenda/composables/useAgendaEventActions.js
| Data: 2026-05-04
|
| Watchers + handlers de save/delete extraídos do AgendaEventDialog.vue
| (A66 sub-sessão 1C-i). Contém SIDE-EFFECTS (supabase, confirm, emit, toast)
| — diferente do composer (1B) que é só state + computeds derivados.
|
| Escopo da 1C-i:
| - Watcher do form.status (confirm dialog cancelado/remarcado + supabase update)
| - Watcher do billingType (limpa campos por tipo)
| - Watcher [paciente_id, dia] (detecta conflito do mesmo paciente no dia)
| - onSave (monta payload + emit)
| - onDelete (avulsa OU série com confirm)
| - onEncerrarSerie (confirm de encerramento série inteira)
|
| Não inclui (vai pra 1C-ii):
| - Watcher do props.modelValue (init form ao abrir — depende de loadPatients,
| ensureServicesLoaded, loadInsurancePlans, _loadCommitmentItemsForEvent)
| - Patient picker handlers (loadPatients, selectPaciente, ...)
| - Billing/items handlers (addItem, removeItem, ...)
| - Series pills handlers
| - Slot selection
|
| Recebe via argumento:
| composer — resultado de useAgendaEventComposer (form, canSave, etc)
| commitmentItems — ref<Item[]> dos serviços/billing
| servicePickerSel — ref do select picker
| selectedPlanService — ref do procedure de convênio
| saveCommitmentItems — function de useCommitmentServices (callback do save)
| props, emit — do componente parent
|--------------------------------------------------------------------------
*/
import { ref, watch } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { supabase } from '@/lib/supabase/client';
import { labelStatusSessao } from './agendaEventHelpers';
const EVENTO_TIPO_SESSAO = 'sessao';
export function useAgendaEventActions({
composer,
commitmentItems,
servicePickerSel,
selectedPlanService,
saveCommitmentItems,
props,
emit
}) {
const toast = useToast();
const confirm = useConfirm();
// Refs internos compartilhados com o .vue (que ainda tem watchers
// próprios em 1C-ii). Expostos no return pra leitura/escrita externa.
const _skipStatusWatch = ref(false);
const _prevStatus = ref(null);
const _restoringConvenio = ref(false);
const samePatientConflict = ref(null);
// ────────────────────────────────────────────────────────────────────
// 1. Watcher do form.status — confirma cancelar/remarcar via dialog
// e persiste no banco IMEDIATAMENTE. Reverte se cancelar.
// Antes vivia no .vue; testado em isolamento agora.
// ────────────────────────────────────────────────────────────────────
watch(
() => composer.form.value?.status,
async (newVal, oldVal) => {
if (_skipStatusWatch.value) return;
if (!composer.isEdit.value || !composer.form.value?.id) return;
if (newVal !== 'cancelado' && newVal !== 'remarcado') return;
_prevStatus.value = oldVal;
const isCancelar = newVal === 'cancelado';
confirm.require({
header: isCancelar ? 'Cancelar sessão' : 'Remarcar sessão',
message: isCancelar
? 'Tem certeza que deseja cancelar esta sessão? O status será salvo imediatamente.'
: 'Tem certeza que deseja marcar esta sessão para remarcação? O status será salvo imediatamente.',
icon: isCancelar ? 'pi pi-times-circle' : 'pi pi-refresh',
acceptLabel: 'Sim, confirmar',
rejectLabel: 'Não',
acceptSeverity: isCancelar ? 'danger' : 'warn',
accept: async () => {
try {
const { data, error } = await supabase
.from('agenda_eventos')
.update({ status: newVal })
.eq('id', composer.form.value.id)
.select()
.single();
if (error) throw error;
toast.add({
severity: 'success',
summary: 'Status atualizado',
detail: `Sessão marcada como ${labelStatusSessao(newVal)}.`,
life: 3000
});
emit('updated', data);
} catch (e) {
toast.add({
severity: 'error',
summary: 'Erro',
detail: e?.message || 'Não foi possível atualizar o status.',
life: 4000
});
composer.form.value.status = _prevStatus.value;
}
},
reject: () => {
composer.form.value.status = _prevStatus.value;
}
});
}
);
// ────────────────────────────────────────────────────────────────────
// 2. Watcher do billingType — quando troca tipo (gratuito/particular/
// convenio), limpa campos dos outros tipos pra não vazar valores.
// ────────────────────────────────────────────────────────────────────
watch(composer.billingType, (val) => {
if (val === 'gratuito') {
commitmentItems.value = [];
composer.form.value.price = 0;
composer.form.value.insurance_plan_id = null;
composer.form.value.insurance_guide_number = null;
composer.form.value.insurance_value = null;
if (selectedPlanService) selectedPlanService.value = null;
}
if (val === 'particular') {
composer.form.value.insurance_plan_id = null;
composer.form.value.insurance_guide_number = null;
composer.form.value.insurance_value = null;
if (selectedPlanService) selectedPlanService.value = null;
}
if (val === 'convenio') {
commitmentItems.value = [];
if (servicePickerSel) servicePickerSel.value = null;
}
});
// ────────────────────────────────────────────────────────────────────
// 3. Watcher [paciente_id, dia] — detecta se o paciente já tem outra
// sessão no mesmo dia. Não bloqueia o save (só informa via UI).
// ────────────────────────────────────────────────────────────────────
watch(
() => [composer.form.value.paciente_id, composer.form.value.dia?.toString()],
async () => {
const pid = composer.form.value.paciente_id;
samePatientConflict.value = null;
if (!pid || !composer.isSessionEvent.value || !composer.visible.value) return;
const d = composer.form.value.dia ? new Date(composer.form.value.dia) : new Date();
const dayStart = new Date(d.getFullYear(), d.getMonth(), d.getDate()).toISOString();
const dayEnd = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).toISOString();
let q = supabase
.from('agenda_eventos')
.select('id, inicio_em, fim_em, titulo')
.eq('patient_id', pid)
.gte('inicio_em', dayStart)
.lt('inicio_em', dayEnd)
.limit(1);
if (composer.form.value.id) q = q.neq('id', composer.form.value.id);
const { data } = await q.maybeSingle();
samePatientConflict.value = data || null;
}
);
// ────────────────────────────────────────────────────────────────────
// Helpers internos (puros) pra montar payload — extraídos pra serem
// testáveis e reutilizáveis. Não dependem de refs reativos diretos,
// recebem o form como argumento.
// ────────────────────────────────────────────────────────────────────
function buildSavePayload({ form, requiresPatient, isSessionEvent, computedTitulo, inicioISO, fimISO }) {
return {
owner_id: form.owner_id,
terapeuta_id: form.terapeuta_id,
paciente_id: requiresPatient ? form.paciente_id : null,
patient_id: requiresPatient ? form.paciente_id : null,
tipo: EVENTO_TIPO_SESSAO,
status: form.status || 'agendado',
titulo: computedTitulo || null,
modalidade: form.modalidade || null,
observacoes: form.observacoes || null,
inicio_em: inicioISO,
fim_em: fimISO,
determined_commitment_id: form.commitment_id || null,
titulo_custom: form.titulo_custom || null,
extra_fields: Object.keys(form.extra_fields || {}).length ? form.extra_fields : null,
price: isSessionEvent ? (form.price ?? null) : null,
insurance_plan_id: isSessionEvent ? (form.insurance_plan_id ?? null) : null,
insurance_guide_number: isSessionEvent ? (form.insurance_guide_number ?? null) : null,
insurance_value: isSessionEvent ? (form.insurance_value ?? null) : null,
insurance_plan_service_id: isSessionEvent ? (form.insurance_plan_service_id ?? null) : null
};
}
function buildRecorrenciaPayload({
recorrenciaType,
diaSemanaRecorrencia,
diasSelecionados,
startTime,
duracaoMin,
dataFimCalculada,
qtdSessoesEfetiva,
serieValorMode,
commitmentItemsList,
ocorrenciasComConflito
}) {
if (recorrenciaType === 'avulsa') return null;
return {
tipo: 'recorrente',
tipoFreq: recorrenciaType,
diaSemana: diaSemanaRecorrencia,
diasSemana: diasSelecionados,
horaInicio: startTime ? `${startTime}:00` : null,
duracaoMin,
dataFim: dataFimCalculada ? dataFimCalculada.toISOString() : null,
qtdSessoes: qtdSessoesEfetiva,
serieValorMode,
commitmentItems: commitmentItemsList.slice(),
conflitos: ocorrenciasComConflito
.filter((o) => o.conflict)
.map((o) => ({ date: o.date.toISOString().slice(0, 10), conflict: o.conflict }))
};
}
// ────────────────────────────────────────────────────────────────────
// 4. onSave — valida (canSave + timeConflict), monta payload e emite.
// ────────────────────────────────────────────────────────────────────
function onSave() {
if (!composer.canSave.value) return;
if (composer.timeConflict.value) {
toast.add({
severity: 'warn',
summary: 'Conflito de horário',
detail: `${composer.timeConflict.value}. Ajuste o horário ou a duração.`,
life: 4500
});
return;
}
const inicioISO = composer.inicioDateTime.value?.toISOString() || null;
const fimISO = composer.fimDateTime.value?.toISOString() || null;
const payload = buildSavePayload({
form: composer.form.value,
requiresPatient: composer.requiresPatient.value,
isSessionEvent: composer.isSessionEvent.value,
computedTitulo: composer.computedTitulo.value,
inicioISO,
fimISO
});
// serieValorMode e similars não estão no composer (1B); são lidos
// do .vue via props.eventActionsExtras se passados, ou null como
// default. 1C-i: assumimos null se não fornecido pra simplificar.
const recorrencia = composer.isSessionEvent.value
? buildRecorrenciaPayload({
recorrenciaType: composer.recorrenciaType.value,
diaSemanaRecorrencia: composer.diaSemanaRecorrencia.value,
diasSelecionados: composer.diasSelecionados.value,
startTime: composer.form.value.startTime,
duracaoMin: composer.form.value.duracaoMin,
dataFimCalculada: composer.dataFimCalculada.value,
qtdSessoesEfetiva: composer.qtdSessoesEfetiva.value,
serieValorMode: 'multiplicar', // default; .vue pode passar outro via _serieValorMode
commitmentItemsList: commitmentItems.value,
ocorrenciasComConflito: composer.ocorrenciasComConflito.value
})
: null;
// Escopo de edição — só quando edita série existente
const emitEditMode = composer.hasSerie.value ? composer.editScope.value : null;
const emitRecurrenceId = composer.hasSerie.value
? props.eventRow?.recurrence_id ?? props.eventRow?.serie_id ?? null
: null;
const emitOriginalDate = composer.hasSerie.value ? props.eventRow?.original_date ?? null : null;
emit('save', {
id: composer.form.value.id,
payload,
recorrencia,
editMode: emitEditMode,
recurrence_id: emitRecurrenceId,
original_date: emitOriginalDate,
// legado — mantido para compatibilidade
serie_id: props.eventRow?.serie_id ?? null,
serviceItems: composer.isSessionEvent.value ? commitmentItems.value.slice() : null,
onSaved: composer.isSessionEvent.value
? async (eventId, { markCustomized = false } = {}) => {
await saveCommitmentItems(eventId, commitmentItems.value, { markCustomized });
}
: null
});
}
// ────────────────────────────────────────────────────────────────────
// 5. onDelete — avulsa: confirm simples + emit(id).
// Série: confirm com escopo (somente_este/seguintes/todos) + emit({id, editMode}).
// ────────────────────────────────────────────────────────────────────
function onDelete() {
if (!composer.form.value.id) return;
if (composer.hasSerie.value) {
const isTodos = composer.editScope.value === 'todos';
confirm.require({
header: isTodos ? 'Encerrar toda a série' : 'Excluir sessão recorrente',
message: isTodos
? 'Todos os agendamentos futuros da série serão removidos permanentemente. Esta sessão será mantida como avulsa. Esta ação é irreversível.'
: 'Esta sessão faz parte de uma série. O que deseja remover?',
icon: isTodos ? 'pi pi-trash' : 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
acceptLabel: isTodos
? 'Sim, encerrar série'
: composer.editScopeOptions.value.find((o) => o.value === composer.editScope.value)?.label || 'Excluir',
rejectLabel: 'Cancelar',
accept: () =>
emit('delete', {
id: composer.form.value.id,
editMode: composer.editScope.value,
recurrence_id: props.eventRow?.recurrence_id ?? props.eventRow?.serie_id ?? null,
original_date: props.eventRow?.original_date ?? null,
serie_id: props.eventRow?.serie_id ?? null
})
});
return;
}
confirm.require({
header: 'Excluir compromisso',
message: 'Tem certeza? Essa ação não pode ser desfeita.',
icon: 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
acceptLabel: 'Excluir',
rejectLabel: 'Cancelar',
accept: () => emit('delete', composer.form.value.id)
});
}
// ────────────────────────────────────────────────────────────────────
// 6. onEncerrarSerie — confirm explícito de encerramento total da série.
// Diferente do onDelete em 'todos' porque pode ser chamado direto
// de um botão dedicado, sem depender de editScope.
// ────────────────────────────────────────────────────────────────────
function onEncerrarSerie() {
confirm.require({
header: 'Encerrar toda a série',
message:
'Todos os agendamentos da série serão removidos permanentemente, incluindo exceções e recorrências. Esta sessão será mantida como avulsa. Esta ação é irreversível.',
icon: 'pi pi-trash',
acceptClass: 'p-button-danger',
acceptLabel: 'Sim, encerrar série',
rejectLabel: 'Cancelar',
accept: () =>
emit('delete', {
id: composer.form.value.id,
editMode: 'todos',
recurrence_id: props.eventRow?.recurrence_id ?? props.eventRow?.serie_id ?? null,
original_date: props.eventRow?.original_date ?? null,
serie_id: props.eventRow?.serie_id ?? null
})
});
}
return {
// refs internos (expostos pra .vue ler/escrever em watchers próprios)
_skipStatusWatch,
_prevStatus,
_restoringConvenio,
samePatientConflict,
// helpers de payload (públicos pra teste isolado)
buildSavePayload,
buildRecorrenciaPayload,
// handlers
onSave,
onDelete,
onEncerrarSerie
};
}
@@ -0,0 +1,485 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/agenda/composables/useAgendaEventComposer.js
| Data: 2026-05-04
|
| Composable factory do AgendaEventDialog — A66 sub-sessão 1B.
|
| Responsabilidade: state + computeds derivados que NÃO dependem de
| efeitos colaterais (sem watchers, sem I/O, sem confirm dialogs). Tudo
| reativo, mas sem side-effects. Watchers e handlers ficam no .vue (1C).
|
| Estrutura:
| 1. Visibility (v-model do Dialog)
| 2. Step + edit scope state
| 3. Recurrence state (avulsa/semanal/quinzenal/diasEspecificos)
| 4. Form factory (resetForm) + form ref
| 5. Computeds derivados de props/state:
| - série (hasSerie, isFirstOccurrence, editScopeOptions, ...)
| - recorrência (proximasOcorrencias, ocorrenciasComConflito, ...)
| - commitment (commitmentCards, selectedCommitment, requiresPatient, ...)
| - permissions (agendaPerms, isArchivedPastEdit, ...)
| - datetime (inicioDateTime, fimDateTime, previewRange, ...)
| - validação (canSave, timeConflict)
|
| Não recebe refs externos (services, insurancePlans, etc) — esses ficam
| no .vue até 1C onde os watchers consomem-os.
|
| commitmentItems é passado via `extras` porque é um array reativo do
| .vue usado em canSave (validação de billing particular).
|
| serieEvents idem — usado em isFirstOccurrence.
|--------------------------------------------------------------------------
*/
import { ref, computed } from 'vue';
import { getPatientAgendaPermissions } from '@/composables/usePatientLifecycle';
import {
isNativeSession,
isForaDoPlano as _isForaDoPlanoPure,
isoToHHMM,
addMinutesDate,
fmtTime,
fmtDateBR,
calcMinutes
} from './agendaEventHelpers';
export function useAgendaEventComposer(props, emit, extras = {}) {
// Refs externos consumidos pelo composer.
// Default = ref([]) pra que o composable seja testável sem que
// o caller precise sempre passar — em produção, .vue passa os
// refs reais (`commitmentItems`, `serieEvents`).
const commitmentItems = extras.commitmentItems ?? ref([]);
const serieEvents = extras.serieEvents ?? ref([]);
// ── 1. Visibilidade (v-model:visible) ──────────────────────────
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
});
// ── 2. Step + edit ─────────────────────────────────────────────
const step = ref(1);
const isEdit = computed(() => !!props.eventRow?.id || !!props.eventRow?.is_occurrence);
const allowBack = computed(() => !props.lockCommitment && !props.presetCommitmentId);
// ── 3. Série (eventRow já carrega; isFirstOccurrence usa serieEvents)
const hasSerie = computed(() =>
!!(props.eventRow?.recurrence_id || props.eventRow?.serie_id || props.eventRow?.is_occurrence)
);
const currentRecurrenceDate = computed(() =>
props.eventRow?.recurrence_date || props.eventRow?.inicio_em?.slice(0, 10) || null
);
const editScope = ref('somente_este');
const isFirstOccurrence = computed(() => {
if (!hasSerie.value) return false;
const rDate = props.eventRow?.recurrence_date || props.eventRow?.original_date;
if (!rDate) return false;
const list = serieEvents.value;
if (list?.length) {
const dates = list
.map((e) => e.recurrence_date || e.original_date)
.filter(Boolean)
.sort();
return dates[0] === rDate;
}
return false;
});
const editScopeOptions = computed(() => [
{ value: 'somente_este', label: 'Somente esta sessão' },
{ value: 'este_e_seguintes', label: 'Esta e as seguintes', disabled: isFirstOccurrence.value },
{ value: 'todos', label: 'Todas da série' },
{ value: 'todos_sem_excecao', label: 'Todas sem exceção' }
]);
// ── 4. Recorrência (criação) ───────────────────────────────────
const recorrenciaType = ref('avulsa');
const diasSelecionados = ref([]);
const qtdSessoesMode = ref('4');
const qtdSessoesCustom = ref(12);
const dataLimiteManual = ref(null);
function toggleDiaSelecionado(dow) {
const idx = diasSelecionados.value.indexOf(dow);
if (idx === -1) diasSelecionados.value.push(dow);
else diasSelecionados.value.splice(idx, 1);
}
const qtdSessoesEfetiva = computed(() => {
if (qtdSessoesMode.value === '4') return 4;
if (qtdSessoesMode.value === '8') return 8;
if (qtdSessoesMode.value === '12') return 12;
return Math.max(1, Number(qtdSessoesCustom.value || 1));
});
// ── 5. Form factory + form ref ─────────────────────────────────
function resetForm() {
const r = props.eventRow;
const startISO = r?.inicio_em || props.initialStartISO || '';
const endISO = r?.fim_em || props.initialEndISO || '';
const duracaoMin = calcMinutes(startISO, endISO) || props.agendaSettings?.session_duration_min || 50;
return {
id: r?.id || null,
owner_id: r?.owner_id || props.ownerId || '',
terapeuta_id: r?.terapeuta_id ?? null,
paciente_id: r?.paciente_id ?? null,
paciente_nome: r?.paciente_nome ?? r?.patient_name ?? '',
paciente_avatar: r?.paciente_avatar ?? '',
paciente_status: r?.paciente_status ?? '',
commitment_id: r?.determined_commitment_id ?? null,
titulo_custom: r?.titulo_custom || '',
status: r?.status || 'agendado',
observacoes: r?.observacoes || '',
dia: startISO ? new Date(startISO) : new Date(),
startTime: startISO ? isoToHHMM(startISO) : null,
duracaoMin,
modalidade: r?.modalidade || 'presencial',
conflito: null,
extra_fields: r?.extra_fields && typeof r.extra_fields === 'object' ? { ...r.extra_fields } : {},
price: r?.price != null ? Number(r.price) : null,
insurance_plan_id: r?.insurance_plan_id ?? null,
insurance_guide_number: r?.insurance_guide_number ?? null,
insurance_value: r?.insurance_value != null ? Number(r.insurance_value) : null,
insurance_plan_service_id: r?.insurance_plan_service_id ?? null
};
}
const form = ref(resetForm());
// ── 6. Recorrência computeds (usam form.dia + state) ──────────
const diaSemanaRecorrencia = computed(() => {
const d = form.value.dia ? new Date(form.value.dia) : new Date();
return d.getDay();
});
const proximasOcorrencias = computed(() => {
if (recorrenciaType.value === 'avulsa' || !form.value.dia) return [];
const result = [];
const total = qtdSessoesEfetiva.value;
if (recorrenciaType.value === 'semanal' || recorrenciaType.value === 'quinzenal') {
const stepDays = recorrenciaType.value === 'quinzenal' ? 14 : 7;
const cursor = new Date(form.value.dia);
while (result.length < total) {
result.push(new Date(cursor));
cursor.setDate(cursor.getDate() + stepDays);
}
} else if (recorrenciaType.value === 'diasEspecificos') {
if (!diasSelecionados.value.length) return [];
const sorted = [...diasSelecionados.value].sort((a, b) => a - b);
const start = new Date(form.value.dia);
const cur = new Date(start);
let safety = 0;
while (result.length < total && safety < 1000) {
if (sorted.includes(cur.getDay()) && cur >= start) result.push(new Date(cur));
cur.setDate(cur.getDate() + 1);
safety++;
}
}
return result;
});
const dataFimCalculada = computed(() => {
const oc = proximasOcorrencias.value;
return oc.length ? oc[oc.length - 1] : null;
});
const totalOcorrencias = computed(() => proximasOcorrencias.value.length);
function isForaDoPlano(d) {
return _isForaDoPlanoPure(d, dataLimiteManual.value);
}
const sessoesForaDoPlano = computed(() =>
proximasOcorrencias.value.filter((d) => isForaDoPlano(d)).length
);
// Conflito por data: folga | feriado | bloqueado | pausa
function conflictForDate(date) {
if (!date) return null;
const dow = date.getDay();
const iso = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
if (props.workRules?.length && !props.workRules.some((r) => Number(r.dia_semana) === dow)) {
return { type: 'folga', label: 'dia de folga' };
}
const feriado = (props.feriados || []).find((f) => {
const fiso = f.date || f.data || f.iso || '';
return String(fiso).slice(0, 10) === iso;
});
if (feriado) return { type: 'feriado', label: `feriado: ${feriado.name || feriado.nome || ''}` };
if ((props.blockedDates || []).includes(iso)) {
return { type: 'bloqueado', label: 'dia bloqueado' };
}
if (form.value.startTime) {
const [sh, sm] = form.value.startTime.split(':').map(Number);
const slotS = sh * 60 + sm;
const slotE = slotS + (form.value.duracaoMin || 50);
const pausas = (props.pausasSemanais || []).filter((p) => p.dia_semana == null || Number(p.dia_semana) === dow);
for (const p of pausas) {
const [ph, pm] = String(p.hora_inicio || '00:00').split(':').map(Number);
const [eh, em] = String(p.hora_fim || '00:00').split(':').map(Number);
if (slotS < eh * 60 + em && slotE > ph * 60 + pm) {
return { type: 'pausa', label: 'horário de pausa' };
}
}
}
return null;
}
const ocorrenciasComConflito = computed(() =>
proximasOcorrencias.value.map((d) => ({
date: d,
conflict: conflictForDate(d)
}))
);
const totalConflitos = computed(() => ocorrenciasComConflito.value.filter((o) => o.conflict).length);
// ── 7. Billing state ───────────────────────────────────────────
const billingType = ref('particular');
// ── 8. Commitment computeds ────────────────────────────────────
const commitmentCards = computed(() => {
const list = Array.isArray(props.commitmentOptions) ? props.commitmentOptions : [];
const prio = new Map([['session', 0]]);
return [...list].sort((a, b) => {
const pa = prio.has(a.native_key) ? prio.get(a.native_key) : 99;
const pb = prio.has(b.native_key) ? prio.get(b.native_key) : 99;
if (pa !== pb) return pa - pb;
return String(a.name || '').localeCompare(String(b.name || ''), 'pt-BR');
});
});
const selectedCommitment = computed(() => {
const id = form.value.commitment_id;
if (!id) return null;
return commitmentCards.value.find((x) => x.id === id) || null;
});
const selectedCommitmentName = computed(() => selectedCommitment.value?.name || '—');
const selectedCommitmentFields = computed(() => {
const fields = selectedCommitment.value?.fields;
return Array.isArray(fields) ? fields : [];
});
const requiresPatient = computed(() => isNativeSession(selectedCommitment.value));
const isSessionEvent = computed(() => requiresPatient.value);
const patientLocked = computed(() => isEdit.value && isSessionEvent.value && !!props.eventRow?.paciente_id);
const hasInsurance = computed(() => !!form.value.insurance_plan_id);
// ── 9. Permissions (status do paciente) ────────────────────────
const agendaPerms = computed(() => getPatientAgendaPermissions(form.value.paciente_status || ''));
const isSessionFuture = computed(() => {
if (!isEdit.value) return true;
const iso = props.eventRow?.inicio_em;
return iso ? new Date(iso) > new Date() : true;
});
const isArchivedPastEdit = computed(
() => isEdit.value && form.value.paciente_status === 'Arquivado' && !isSessionFuture.value
);
const isInativoFutureEdit = computed(
() => isEdit.value && form.value.paciente_status === 'Inativo' && isSessionFuture.value
);
const statusOptionsFiltered = computed(() => [
{ label: 'Agendado', value: 'agendado' },
{ label: 'Realizado', value: 'realizado' },
{ label: 'Faltou', value: 'faltou' },
{ label: 'Cancelado', value: 'cancelado' },
{ label: 'Remarcar', value: 'remarcado', disabled: isInativoFutureEdit.value }
]);
// ── 10. Datetime ───────────────────────────────────────────────
const startTimeDate = computed({
get() {
const t = form.value.startTime;
if (!t) return null;
const [h, m] = String(t).split(':').map(Number);
const d = new Date();
d.setHours(h, m, 0, 0);
return d;
},
set(v) {
if (!v) {
form.value.startTime = null;
return;
}
form.value.startTime = `${String(v.getHours()).padStart(2, '0')}:${String(v.getMinutes()).padStart(2, '0')}`;
}
});
const inicioDateTime = computed(() => {
if (!form.value.dia || !form.value.startTime) return null;
const d = new Date(form.value.dia);
const [hh, mm] = String(form.value.startTime).split(':').map(Number);
d.setHours(hh, mm, 0, 0);
return d;
});
const fimDateTime = computed(() => {
if (!inicioDateTime.value) return null;
return addMinutesDate(inicioDateTime.value, Number(form.value.duracaoMin || 50));
});
const dataHoraDisplay = computed(() => {
const parts = [];
if (form.value.dia) parts.push(fmtDateBR(form.value.dia));
if (form.value.startTime) parts.push(form.value.startTime);
return parts.join(' • ');
});
const previewRange = computed(() => {
if (!inicioDateTime.value || !fimDateTime.value) return '—';
return `${fmtDateBR(inicioDateTime.value)}${fmtTime(inicioDateTime.value)}${fmtTime(fimDateTime.value)}`;
});
// ── 11. Título ─────────────────────────────────────────────────
const computedTitulo = computed(() => {
const forced = String(form.value.titulo_custom || '').trim();
if (forced) return forced;
const comp = selectedCommitmentName.value || 'Compromisso';
if (requiresPatient.value) {
const nome = String(form.value.paciente_nome || '').trim();
return nome ? `${nome} [${comp}]` : comp;
}
return comp;
});
const headerTitle = computed(() => {
if (isEdit.value) return 'Editar compromisso';
return step.value === 1 ? 'Novo compromisso — escolha o tipo' : 'Novo compromisso';
});
// ── 12. Validação (canSave) ────────────────────────────────────
// Núcleo do business logic: cobre 6 categorias de check pra
// habilitar o botão Salvar. Testado isoladamente em 1B.
const canSave = computed(() => {
if (!form.value.owner_id) return false;
if (!form.value.dia) return false;
if (!form.value.startTime) return false;
// commitment_id obrigatório só na criação (sessões antigas no DB
// podem ter null — não bloquear edição por isso)
if (!isEdit.value && !form.value.commitment_id) return false;
if (requiresPatient.value && !form.value.paciente_id) return false;
if (isSessionEvent.value && billingType.value === 'particular' && commitmentItems.value.length === 0) return false;
// Restrições por status do paciente
if (isSessionEvent.value && form.value.paciente_status) {
const perms = agendaPerms.value;
if (!isEdit.value && !perms.canCreateSession) return false;
if (!isEdit.value && recorrenciaType.value !== 'avulsa' && !perms.canCreateRecurrence) return false;
if (isArchivedPastEdit.value) return false;
}
return true;
});
// ── 13. Conflito de horário (timeConflict) ─────────────────────
// Pre-check client-side ANTES do PATCH ir pro DB. Sem isso, o constraint
// EXCLUDE do Postgres dispara 400 no console toda vez que o user salva
// num horário ocupado.
const timeConflict = computed(() => {
if (!form.value.dia || !form.value.startTime || !inicioDateTime.value) return null;
const dur = form.value.duracaoMin || 50;
const breakMin = props.agendaSettings?.session_break_min || 0;
const slotS = inicioDateTime.value.getTime();
const slotE = slotS + dur * 60000;
const d = new Date(form.value.dia);
const dayISO = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const currentRow = props.eventRow;
const dayEvts = (props.allEvents || []).filter((e) => {
if (!e.inicio_em) return false;
// Exclui evento real pelo id
if (form.value.id && e.id === form.value.id) return false;
// Exclui ocorrência virtual pelo recurrence_id + original_date
if (currentRow?.is_occurrence && e.is_occurrence && e.recurrence_id && e.recurrence_id === currentRow.recurrence_id && String(e.original_date || '').slice(0, 10) === String(currentRow.original_date || '').slice(0, 10)) return false;
const es = new Date(e.inicio_em);
return `${es.getFullYear()}-${String(es.getMonth() + 1).padStart(2, '0')}-${String(es.getDate()).padStart(2, '0')}` === dayISO;
});
for (const evt of dayEvts) {
const evtS = new Date(evt.inicio_em).getTime();
const evtE = new Date(evt.fim_em || evt.inicio_em).getTime() + breakMin * 60000;
if (slotS < evtE && slotE > evtS) {
const nome = evt.paciente_nome || 'outro compromisso';
return `Conflito com "${nome}" às ${fmtTime(new Date(evt.inicio_em))}`;
}
}
// Pausas do dia
const dow = d.getDay();
const pausas = (props.pausasSemanais || []).filter((p) => p.hora_inicio && p.hora_fim && (p.dia_semana == null || Number(p.dia_semana) === dow));
if (pausas.length && form.value.startTime) {
const [sh, sm] = form.value.startTime.split(':').map(Number);
const sS = sh * 60 + sm;
const sE = sS + dur;
for (const p of pausas) {
const [ph, pm] = String(p.hora_inicio).split(':').map(Number);
const [eh, em] = String(p.hora_fim).split(':').map(Number);
const pS = ph * 60 + pm;
const pE = eh * 60 + em;
if (sS < pE && sE > pS) return `Horário coincide com uma pausa (${p.hora_inicio}${p.hora_fim})`;
}
}
return null;
});
return {
// refs
visible,
step,
editScope,
recorrenciaType,
diasSelecionados,
qtdSessoesMode,
qtdSessoesCustom,
dataLimiteManual,
billingType,
form,
// edit/série computeds
isEdit,
allowBack,
hasSerie,
currentRecurrenceDate,
isFirstOccurrence,
editScopeOptions,
// recorrência computeds
qtdSessoesEfetiva,
diaSemanaRecorrencia,
proximasOcorrencias,
dataFimCalculada,
totalOcorrencias,
sessoesForaDoPlano,
ocorrenciasComConflito,
totalConflitos,
// commitment computeds
commitmentCards,
selectedCommitment,
selectedCommitmentName,
selectedCommitmentFields,
requiresPatient,
isSessionEvent,
patientLocked,
hasInsurance,
// permissions computeds
agendaPerms,
isSessionFuture,
isArchivedPastEdit,
isInativoFutureEdit,
statusOptionsFiltered,
// datetime computeds
startTimeDate,
inicioDateTime,
fimDateTime,
dataHoraDisplay,
previewRange,
// título computeds
computedTitulo,
headerTitle,
// validação
canSave,
timeConflict,
// métodos
toggleDiaSelecionado,
isForaDoPlano,
conflictForDate,
resetForm
};
}
@@ -0,0 +1,474 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/agenda/composables/useAgendaEventLifecycle.js
| Data: 2026-05-04
|
| A66 sub-sessão 1C-ii-b — lifecycle do AgendaEventDialog:
| - Watcher props.modelValue (init form ao abrir — orquestra
| loadPatients/ensureServicesLoaded/loadInsurancePlans/
| _loadCommitmentItemsForEvent + reset de refs)
| - Watcher [tenantId, restrictPatients, patientScopeOwnerId]
| - Watcher [dia, startTime] (solicitação pendente do agendador público)
| - Watcher [dia, modalidade] (online slots loader)
| - Series pills (loadSerieEvents + 4 handlers + generateRuleDates)
| - selectSlot
| - Quick-creates wiring (service + insurance)
| - onSendManualReminder (lembrete WhatsApp)
|
| Recebe via argumento:
| composer — composer (1B)
| actions — actions (1C-i): _skipStatusWatch, _restoringConvenio,
| samePatientConflict
| pickerBilling — picker/billing (1C-ii-a): ensureServicesLoaded,
| _loadCommitmentItemsForEvent, clearPatientsCache,
| loadPatients, addItem
| commitmentItems — ref<Item[]>
| serieEvents — ref<SerieEvent[]>
| servicePickerSel — ref do picker
| selectedPlanService — ref do procedure de convênio
| serieValorMode — ref<'multiplicar' | 'dividir'>
| services — ref<Service[]> (de useServices)
| loadServices — fn(ownerId)
| loadInsurancePlans — fn(ownerId)
| props — props do dialog
| emit — emitter ('updateSeriesEvent', 'editSeriesOccurrence', 'delete')
| confirm — useConfirm()
| toast — useToast()
|--------------------------------------------------------------------------
*/
import { ref, computed, watch, nextTick } from 'vue';
import { supabase } from '@/lib/supabase/client';
export function generateRuleDates(rule) {
const { type, interval = 1, weekdays = [], start_date, end_date, max_occurrences } = rule || {};
if (!start_date || !weekdays?.length) return [];
const maxOcc = Math.min(max_occurrences || 365, 365);
const endLimit = end_date ? new Date(end_date + 'T23:59:59') : null;
const dates = [];
if (type === 'custom_weekdays') {
const cursor = new Date(start_date + 'T12:00:00');
let safety = 0;
while (dates.length < maxOcc && safety < 2000) {
safety++;
if (endLimit && cursor > endLimit) break;
if (weekdays.includes(cursor.getDay())) dates.push(cursor.toISOString().slice(0, 10));
cursor.setDate(cursor.getDate() + 1);
}
} else {
// weekly (interval=1) ou quinzenal (interval=2)
const cursor = new Date(start_date + 'T12:00:00');
while (dates.length < maxOcc) {
if (endLimit && cursor > endLimit) break;
dates.push(cursor.toISOString().slice(0, 10));
cursor.setDate(cursor.getDate() + 7 * (interval || 1));
}
}
return dates;
}
export function useAgendaEventLifecycle({
composer,
actions,
pickerBilling,
commitmentItems,
serieEvents,
servicePickerSel,
selectedPlanService,
serieValorMode,
services,
loadServices,
loadInsurancePlans,
props,
emit,
confirm,
toast
}) {
// ── refs locais ────────────────────────────────────────────
const solicitacaoPendente = ref(null);
const onlineSlots = ref([]);
const loadingOnlineSlots = ref(false);
const serieLoading = ref(false);
const pillDeleteMenuRef = ref(null);
const pillDeleteTarget = ref(null);
const sendingReminder = ref(false);
const serviceQuickDlgOpen = ref(false);
const insuranceQuickDlgOpen = ref(false);
// ── computeds locais ───────────────────────────────────────
const serieCountByStatus = computed(() => {
const counts = {};
for (const ev of serieEvents.value) {
const s = ev._status || 'agendado';
counts[s] = (counts[s] || 0) + 1;
}
return counts;
});
const pillDeleteMenuItems = computed(() => {
if (!pillDeleteTarget.value) return [];
const ev = pillDeleteTarget.value;
return [
{ label: 'Remover apenas esta', icon: 'pi pi-minus-circle', command: () => onPillDelete(ev, 'somente_este') },
{ label: 'Remover esta e as seguintes', icon: 'pi pi-forward', command: () => onPillDelete(ev, 'este_e_seguintes') },
{ separator: true },
{ label: 'Remover todas as futuras', icon: 'pi pi-trash', command: () => onPillDelete(ev, 'todos') }
];
});
// ── series pills ───────────────────────────────────────────
async function loadSerieEvents() {
const rid = props.eventRow?.recurrence_id ?? props.eventRow?.serie_id ?? null;
if (!rid) {
serieEvents.value = [];
return;
}
serieLoading.value = true;
try {
const { data: rule, error: ruleErr } = await supabase.from('recurrence_rules').select('*').eq('id', rid).maybeSingle();
if (ruleErr) throw ruleErr;
const { data: excData } = await supabase.from('recurrence_exceptions').select('original_date, type, reason').eq('recurrence_id', rid);
const exMap = new Map((excData || []).map((e) => [e.original_date, e]));
const { data: realData } = await supabase
.from('agenda_eventos')
.select('id, inicio_em, fim_em, status, recurrence_date')
.eq('recurrence_id', rid)
.is('mirror_of_event_id', null)
.order('inicio_em', { ascending: true });
const realMap = new Map((realData || []).map((e) => [e.recurrence_date || e.inicio_em?.slice(0, 10), e]));
const dates = rule ? generateRuleDates(rule) : [];
const startTime = rule?.start_time || '00:00:00';
const durMin = rule?.duration_min || 50;
const list = dates.map((dateISO) => {
const real = realMap.get(dateISO);
const exc = exMap.get(dateISO);
const isCancelled = exc?.type === 'cancel_session' || exc?.type === 'holiday_block';
const inicioStr = real?.inicio_em || `${dateISO}T${startTime}`;
const fimDate = new Date(`${dateISO}T${startTime}`);
fimDate.setMinutes(fimDate.getMinutes() + durMin);
const fimStr = real?.fim_em || fimDate.toISOString();
return {
id: real?.id || null,
inicio_em: inicioStr,
fim_em: fimStr,
status: real?.status || (isCancelled ? 'cancelado' : 'agendado'),
recurrence_date: dateISO,
_status: real?.status || (isCancelled ? 'cancelado' : 'agendado'),
_is_virtual: !real?.id,
_cancelled: isCancelled,
_reason: exc?.reason || null
};
});
for (const [dateISO, real] of realMap) {
if (!dates.includes(dateISO)) {
list.push({
id: real.id,
inicio_em: real.inicio_em,
fim_em: real.fim_em,
status: real.status || 'agendado',
recurrence_date: dateISO,
_status: real.status || 'agendado',
_is_virtual: false,
_cancelled: false,
_reason: null
});
}
}
list.sort((a, b) => new Date(a.inicio_em) - new Date(b.inicio_em));
serieEvents.value = list;
} catch (e) {
console.error('[serie] erro ao carregar:', e);
serieEvents.value = [];
} finally {
serieLoading.value = false;
}
}
function onPillEditClick(ev) {
emit('editSeriesOccurrence', {
id: ev.id,
recurrence_date: ev.recurrence_date,
inicio_em: ev.inicio_em,
fim_em: ev.fim_em,
is_virtual: ev._is_virtual
});
}
function onPillStatusChange(ev) {
emit('updateSeriesEvent', {
id: ev.id,
status: ev._status,
recurrence_date: ev.recurrence_date,
inicio_em: ev.inicio_em,
fim_em: ev.fim_em,
is_virtual: ev._is_virtual
});
if (ev._is_virtual) {
setTimeout(() => loadSerieEvents(), 700);
}
}
function onPillDeleteClick(ev, event) {
pillDeleteTarget.value = ev;
nextTick(() => pillDeleteMenuRef.value?.toggle(event));
}
function onPillDelete(ev, mode) {
const isTodos = mode === 'todos';
confirm.require({
header: isTodos ? 'Encerrar toda a série' : 'Excluir sessão recorrente',
message: isTodos
? 'Todos os agendamentos futuros da série serão removidos permanentemente. Esta sessão será mantida como avulsa. Esta ação é irreversível.'
: mode === 'este_e_seguintes'
? 'Esta sessão e todas as seguintes serão removidas. Tem certeza?'
: 'Esta sessão será cancelada. Tem certeza?',
icon: isTodos ? 'pi pi-trash' : 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
acceptLabel: isTodos ? 'Sim, encerrar série' : 'Confirmar',
rejectLabel: 'Cancelar',
accept: () =>
emit('delete', {
id: ev.id,
editMode: mode,
recurrence_id: props.eventRow?.recurrence_id ?? props.eventRow?.serie_id ?? null,
original_date: ev.recurrence_date || (ev.inicio_em ? ev.inicio_em.slice(0, 10) : null),
serie_id: props.eventRow?.serie_id ?? null
})
});
}
// ── slot selection ─────────────────────────────────────────
function selectSlot(hhmm) {
const [h, m] = String(hhmm).split(':').map(Number);
const d = new Date();
d.setHours(h, m, 0, 0);
composer.startTimeDate.value = d;
}
// ── quick-creates ──────────────────────────────────────────
function openServiceQuickCreate() {
serviceQuickDlgOpen.value = true;
}
async function onServiceCreated(svc) {
await loadServices(props.ownerId);
if (svc?.id) {
const list = services?.value;
const fresh = (Array.isArray(list) ? list.find((s) => s.id === svc.id) : null) || svc;
if (typeof pickerBilling.addItem === 'function') {
pickerBilling.addItem(fresh);
}
}
}
function openInsuranceQuickCreate() {
insuranceQuickDlgOpen.value = true;
}
async function onInsuranceCreated(plan) {
await loadInsurancePlans(props.planOwnerId || props.ownerId);
if (plan?.id) {
composer.form.value.insurance_plan_id = plan.id;
}
}
// ── lembrete WhatsApp manual (8.2) ─────────────────────────
async function onSendManualReminder() {
if (!composer.form.value?.id) return;
confirm.require({
header: 'Enviar lembrete WhatsApp?',
message: `Vou mandar o template "lembrete_sessao" pra ${composer.form.value.paciente_nome || 'o paciente'} agora. Pode disparar?`,
icon: 'pi pi-whatsapp',
acceptLabel: 'Enviar',
rejectLabel: 'Cancelar',
accept: async () => {
sendingReminder.value = true;
try {
const { data, error } = await supabase.functions.invoke('send-session-reminder-manual', {
body: { event_id: composer.form.value.id }
});
if (error || !data?.ok) {
const err = data?.error || error?.message || 'unknown_error';
let friendly = err;
if (err === 'no_phone') friendly = 'Paciente sem telefone cadastrado.';
else if (err === 'invalid_phone') friendly = 'Telefone do paciente inválido.';
else if (err === 'no_active_channel') friendly = 'Nenhum canal WhatsApp ativo. Configure em Configurações → WhatsApp.';
else if (err === 'template_not_found') friendly = 'Template "lembrete_sessao" não encontrado. Configure em Configurações → WhatsApp.';
else if (err === 'forbidden') friendly = 'Você não tem permissão pra enviar por este canal.';
else if (String(err).startsWith('send_failed')) friendly = 'Não conseguimos enviar. Verifique a conexão do WhatsApp.';
throw new Error(friendly);
}
toast.add({ severity: 'success', summary: 'Lembrete enviado', detail: data.to ? `Para ${data.to}` : undefined, life: 3500 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao enviar lembrete', detail: e.message, life: 5000 });
} finally {
sendingReminder.value = false;
}
}
});
}
// ── watchers ───────────────────────────────────────────────
// Init form ao abrir o dialog (orquestra tudo)
watch(
() => props.modelValue,
async (open) => {
if (!open) return;
await nextTick();
actions._skipStatusWatch.value = true;
composer.form.value = composer.resetForm();
await nextTick();
actions._skipStatusWatch.value = false;
actions.samePatientConflict.value = null;
composer.recorrenciaType.value = 'avulsa';
composer.diasSelecionados.value = [];
composer.dataLimiteManual.value = null;
composer.qtdSessoesMode.value = '4';
composer.qtdSessoesCustom.value = 12;
composer.editScope.value = 'somente_este';
if (serieValorMode) serieValorMode.value = 'multiplicar';
if (composer.isEdit.value && composer.form.value.paciente_id && !composer.form.value.paciente_nome) {
supabase
.from('patients')
.select('id, nome_completo')
.eq('id', composer.form.value.paciente_id)
.maybeSingle()
.then(({ data }) => {
if (data?.nome_completo) composer.form.value.paciente_nome = data.nome_completo;
});
}
if (composer.hasSerie.value) loadSerieEvents();
else serieEvents.value = [];
if (composer.isEdit.value) {
composer.step.value = 2;
} else {
const preset = props.presetCommitmentId;
if (preset) {
composer.form.value.commitment_id = preset;
composer.step.value = 2;
} else composer.step.value = 1;
}
pickerBilling.clearPatientsCache();
if (composer.requiresPatient.value) pickerBilling.loadPatients(true);
pickerBilling.ensureServicesLoaded();
const insuranceOwner = props.planOwnerId || props.ownerId;
if (insuranceOwner) {
await loadInsurancePlans(insuranceOwner);
}
selectedPlanService.value = null;
actions._restoringConvenio.value = false;
commitmentItems.value = [];
servicePickerSel.value = null;
if (composer.isEdit.value && (composer.form.value.id || props.eventRow?.recurrence_id)) {
pickerBilling._loadCommitmentItemsForEvent(composer.form.value.id);
} else {
composer.billingType.value = 'particular';
}
}
);
// Tenant/scope mudou — recarrega lista de pacientes
watch(
() => [props.tenantId, props.restrictPatientsToOwner, props.patientScopeOwnerId],
() => {
if (!composer.visible.value) return;
pickerBilling.clearPatientsCache();
if (composer.requiresPatient.value) pickerBilling.loadPatients(true);
}
);
// Solicitação pendente do agendador público no horário escolhido
watch(
() => [composer.form.value.dia?.toString(), composer.form.value.startTime],
async ([dia, startTime]) => {
solicitacaoPendente.value = null;
if (!composer.isSessionEvent.value || !composer.visible.value || composer.isEdit.value) return;
if (!props.ownerId || !dia || !startTime) return;
const d = new Date(composer.form.value.dia);
const isoDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const { data } = await supabase
.from('agendador_solicitacoes')
.select('id, paciente_nome, paciente_sobrenome, paciente_email')
.eq('owner_id', props.ownerId)
.eq('status', 'pendente')
.eq('data_solicitada', isoDate)
.eq('hora_solicitada', startTime)
.maybeSingle();
solicitacaoPendente.value = data || null;
}
);
// Online slots: depende de [dia, modalidade]
watch(
[() => composer.form.value.dia, () => composer.form.value.modalidade],
async ([dia, mod]) => {
if (mod !== 'online' || !dia || !props.ownerId) {
onlineSlots.value = [];
return;
}
const dow = new Date(dia).getDay();
loadingOnlineSlots.value = true;
try {
const { data } = await supabase
.from('agenda_online_slots')
.select('time')
.eq('owner_id', props.ownerId)
.eq('weekday', dow)
.eq('enabled', true)
.order('time');
onlineSlots.value = (data || []).map((s) => ({ hhmm: String(s.time || '').slice(0, 5) }));
} catch {
onlineSlots.value = [];
} finally {
loadingOnlineSlots.value = false;
}
},
{ immediate: true }
);
return {
// refs
solicitacaoPendente,
onlineSlots,
loadingOnlineSlots,
serieLoading,
pillDeleteMenuRef,
pillDeleteTarget,
sendingReminder,
serviceQuickDlgOpen,
insuranceQuickDlgOpen,
// computeds
serieCountByStatus,
pillDeleteMenuItems,
// series
loadSerieEvents,
onPillEditClick,
onPillStatusChange,
onPillDeleteClick,
onPillDelete,
// slot
selectSlot,
// quick-creates
openServiceQuickCreate,
onServiceCreated,
openInsuranceQuickCreate,
onInsuranceCreated,
// reminder
onSendManualReminder
};
}
@@ -0,0 +1,378 @@
/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/agenda/composables/useAgendaEventPickerBilling.js
| Data: 2026-05-04
|
| A66 sub-sessão 1C-ii-a — handlers de patient picker + billing items +
| 2 watchers (commitment_id auto-fill price, insurance_plan_id limpa
| items + reset campos).
|
| Não inclui (vai pra 1C-ii-b):
| - Watcher props.modelValue (init form ao abrir — tem dependências
| em loadPatients/ensureServicesLoaded/loadInsurancePlans/
| _loadCommitmentItemsForEvent + supabase pra buscar nome do paciente)
| - Watchers de online slots, solicitações pendentes
| - Series pills handlers
| - Slot selection
| - Quick-creates wiring
| - onSendManualReminder
|
| Recebe via argumento:
| composer — refs + computeds do composer (1B)
| _actions — refs internos do actions (1C-i): _restoringConvenio
| commitmentItems — ref<Item[]>
| servicePickerSel — ref do select picker (limpado ao trocar convenio)
| selectedPlanService — ref do procedure de convênio
| services — ref<Service[]> do useServices
| loadServices — fn(ownerId) do useServices
| getDefaultPrice — fn() do useServices (preço default sugerido)
| planServices — computed<PlanService[]> (de useInsurancePlans + form)
| loadActiveDiscount — fn(ownerId, patientId) do usePatientDiscounts
| _csLoadItems — fn(eventId) do useCommitmentServices
| _csLoadItemsOrTemplate — fn(eventId, ruleId, opts) do useCommitmentServices
| isDynamic — computed<boolean>
| props — props do componente parent
|--------------------------------------------------------------------------
*/
import { ref, watch, nextTick } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { calcFinalPrice } from './agendaEventHelpers';
export function useAgendaEventPickerBilling({
composer,
actions,
commitmentItems,
servicePickerSel,
selectedPlanService,
services,
loadServices,
getDefaultPrice,
planServices,
loadActiveDiscount,
_csLoadItems,
_csLoadItemsOrTemplate,
isDynamic,
props
}) {
// ── Patient picker state ───────────────────────────────────────
const pacientePickerOpen = ref(false);
const pacienteSearch = ref('');
const pacientesLoading = ref(false);
const pacientesError = ref('');
const patients = ref([]);
// ── Cadastro rápido ────────────────────────────────────────────
const cadRapidoOpen = ref(false);
// ── Services lazy-load gate ────────────────────────────────────
let _servicesLoaded = false;
// ────────────────────────────────────────────────────────────────
// Services pré-carga (lazy: só uma vez por sessão do dialog)
// ────────────────────────────────────────────────────────────────
async function ensureServicesLoaded() {
if (_servicesLoaded || !props.ownerId) return;
_servicesLoaded = true;
await loadServices(props.ownerId);
}
// Reseta o gate (chamado pelo watcher de open na 1C-ii-b)
function resetServicesGate() {
_servicesLoaded = false;
}
function applyDefaultPrice() {
// Skip particular: preço vem dos commitmentItems
if (composer.billingType.value === 'particular') return;
// Só auto-preenche em criação (edição preserva o valor salvo)
if (!composer.isEdit.value) {
const suggested = getDefaultPrice();
if (suggested != null) composer.form.value.price = suggested;
}
}
// ────────────────────────────────────────────────────────────────
// Billing items (commitment_services)
// ────────────────────────────────────────────────────────────────
/**
* Adiciona um serviço ao billing. Regras:
* - Não duplica: se já existe item do mesmo service_id, incrementa quantity
* - Aplica desconto ativo do paciente (se houver)
* - Recalcula final_price via helper puro
*/
async function addItem(svc) {
if (!svc?.id) return;
const existing = commitmentItems.value.find((i) => i.service_id === svc.id);
if (existing) {
existing.quantity++;
existing.final_price = calcFinalPrice(existing.unit_price, existing.quantity, existing.discount_pct, existing.discount_flat);
return;
}
const unit_price = Number(svc.price);
const patientId = composer.form.value.patient_id ?? composer.form.value.paciente_id ?? null;
let discount_pct = 0;
let discount_flat = 0;
if (patientId && props.ownerId) {
const discount = await loadActiveDiscount(props.ownerId, patientId);
if (discount) {
discount_pct = Number(discount.discount_pct ?? 0);
discount_flat = Number(discount.discount_flat ?? 0);
}
}
commitmentItems.value.push({
service_id: svc.id,
service_name: svc.name,
quantity: 1,
unit_price,
discount_pct,
discount_flat,
final_price: calcFinalPrice(unit_price, 1, discount_pct, discount_flat)
});
}
function removeItem(index) {
commitmentItems.value.splice(index, 1);
// Lista vazia em modo dinâmico → restaura duração padrão
if (commitmentItems.value.length === 0 && isDynamic.value) {
composer.form.value.duracaoMin = props.agendaSettings?.session_duration_min ?? 50;
}
}
function onItemChange(item) {
item.final_price = calcFinalPrice(item.unit_price, item.quantity, item.discount_pct, item.discount_flat);
}
/**
* Carrega items do evento (ou template da série) e detecta billingType.
* Heurística: se eventRow tem insurance_plan_id → 'convenio'; senão,
* presença de items → 'particular', vazio → 'gratuito'.
*/
async function _loadCommitmentItemsForEvent(eventId) {
const ruleId = props.eventRow?.recurrence_id ?? null;
const isCustomized = props.eventRow?.services_customized ?? false;
const origPlanId = props.eventRow?.insurance_plan_id ?? null;
const origGuide = props.eventRow?.insurance_guide_number ?? null;
const origInsValue = props.eventRow?.insurance_value != null ? Number(props.eventRow.insurance_value) : null;
function applyConvenio() {
const origPsId = props.eventRow?.insurance_plan_service_id ?? null;
actions._restoringConvenio.value = true;
composer.form.value.insurance_plan_id = origPlanId;
composer.form.value.insurance_guide_number = origGuide;
composer.form.value.insurance_value = origInsValue;
composer.form.value.insurance_plan_service_id = origPsId;
composer.billingType.value = 'convenio';
nextTick(() => {
if (origPsId && planServices.value.find((s) => s.id === origPsId)) {
selectedPlanService.value = origPsId;
} else {
selectedPlanService.value = null;
}
actions._restoringConvenio.value = false;
});
}
if (!eventId && !ruleId) {
commitmentItems.value = [];
if (origPlanId) applyConvenio();
else composer.billingType.value = 'particular';
return;
}
try {
commitmentItems.value = ruleId
? await _csLoadItemsOrTemplate(eventId, ruleId, { allowEmpty: isCustomized })
: await _csLoadItems(eventId);
if (origPlanId) applyConvenio();
else composer.billingType.value = commitmentItems.value.length > 0 ? 'particular' : 'gratuito';
} catch (e) {
// eslint-disable-next-line no-console
console.warn('[useAgendaEventPickerBilling] commitment_services load error:', e?.message);
commitmentItems.value = [];
if (origPlanId) applyConvenio();
else composer.billingType.value = 'gratuito';
}
}
/**
* Setter do procedimento de convênio (form.insurance_plan_service_id).
* Quando muda, atualiza form.insurance_value baseado no value do procedimento.
*/
function onProcedureSelect(psId) {
composer.form.value.insurance_plan_service_id = psId ?? null;
if (!psId) {
composer.form.value.insurance_value = null;
return;
}
const ps = planServices.value.find((s) => s.id === psId);
composer.form.value.insurance_value = ps?.value != null ? Number(ps.value) : null;
}
// ────────────────────────────────────────────────────────────────
// Patient picker
// ────────────────────────────────────────────────────────────────
function selectCommitment(c) {
if (!c?.id) return;
composer.form.value.commitment_id = c.id;
composer.form.value.extra_fields = {};
if (Array.isArray(c.fields)) {
for (const f of c.fields) composer.form.value.extra_fields[f.key] = '';
}
composer.step.value = 2;
if (composer.requiresPatient.value) loadPatients(true);
}
function goBack() {
if (composer.isEdit.value || !composer.allowBack.value) return;
composer.step.value = 1;
composer.form.value.commitment_id = null;
composer.form.value.paciente_id = null;
composer.form.value.paciente_nome = '';
}
function openPacientePicker() {
if (!composer.requiresPatient.value) return;
pacientePickerOpen.value = true;
loadPatients(false);
}
function clearPatientsCache() {
patients.value = [];
pacientesError.value = '';
pacienteSearch.value = '';
}
async function loadPatients(force = false) {
try {
if (pacientesLoading.value) return;
if (!force && patients.value?.length) return;
pacientesError.value = '';
pacientesLoading.value = true;
let q = supabase
.from('patients')
.select('id,nome_completo,email_principal,telefone,status,avatar_url,tenant_id,responsible_member_id,created_at')
.order('created_at', { ascending: false })
.limit(500);
if (props.tenantId) q = q.eq('tenant_id', props.tenantId);
if (props.restrictPatientsToOwner && props.patientScopeOwnerId) {
q = q.eq('responsible_member_id', props.patientScopeOwnerId);
}
const { data, error } = await q;
if (error) throw error;
patients.value = (data || []).map((r) => ({
id: r.id,
nome: r.nome_completo ?? '',
email: r.email_principal ?? '',
telefone: r.telefone ?? '',
status: r.status ?? '',
avatar_url: r.avatar_url ?? ''
}));
} catch (e) {
pacientesError.value = e?.message || 'Falha ao carregar pacientes.';
patients.value = [];
} finally {
pacientesLoading.value = false;
}
}
function selectPaciente(p) {
if (!p?.id) return;
composer.form.value.paciente_id = p.id;
composer.form.value.paciente_nome = p.nome || '';
composer.form.value.paciente_avatar = p.avatar_url || '';
pacientePickerOpen.value = false;
}
function clearPaciente() {
composer.form.value.paciente_id = null;
composer.form.value.paciente_nome = '';
composer.form.value.paciente_avatar = '';
actions.samePatientConflict.value = null;
}
function openCadastroRapido() {
cadRapidoOpen.value = true;
}
function abrirCadastroCompleto() {
if (!props.newPatientRoute) return;
// Abre em nova aba pra o user voltar pro dialog depois
window.open(props.newPatientRoute, '_blank', 'noopener');
}
// ────────────────────────────────────────────────────────────────
// Watchers
// ────────────────────────────────────────────────────────────────
/**
* Auto-fill de price quando o user troca commitment em criação.
* Ignorado em edição pra preservar o valor salvo.
*/
watch(
() => composer.form.value.commitment_id,
async (newId) => {
if (!newId || composer.isEdit.value || !composer.visible.value) return;
await ensureServicesLoaded();
applyDefaultPrice();
}
);
/**
* Limpa procedure + campos de convênio quando muda plano.
* Quando seleciona convênio: zera items (exclusividade convênio vs serviços).
* `_restoringConvenio` ignora o watch durante restauração de eventRow editado.
*/
watch(
() => composer.form.value.insurance_plan_id,
(planId) => {
if (actions._restoringConvenio.value) return;
selectedPlanService.value = null;
composer.form.value.insurance_plan_service_id = null;
if (!planId) {
composer.form.value.insurance_value = null;
composer.form.value.insurance_guide_number = null;
return;
}
commitmentItems.value = [];
servicePickerSel.value = null;
}
);
return {
// refs do picker
pacientePickerOpen,
pacienteSearch,
pacientesLoading,
pacientesError,
patients,
cadRapidoOpen,
// billing/services
ensureServicesLoaded,
resetServicesGate,
applyDefaultPrice,
addItem,
removeItem,
onItemChange,
_loadCommitmentItemsForEvent,
onProcedureSelect,
// picker
selectCommitment,
goBack,
openPacientePicker,
clearPatientsCache,
loadPatients,
selectPaciente,
clearPaciente,
openCadastroRapido,
abrirCadastroCompleto
};
}