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,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);
});
});