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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,136 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/agenda/components/InsurancePlanQuickCreateDialog.vue
| Data: 2026-05-04
|
| Mini-dialog pra cadastrar convênio SEM sair do AgendaEventDialog.
| Mesmo pattern do ServiceQuickCreateDialog emite `created` com a row
| inserida, parent pré-seleciona.
|
| Campos mínimos (obrigatórios no schema):
| name, owner_id, tenant_id
| Opcionais:
| default_value (valor base sugerido), notes
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, watch } from 'vue';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
const props = defineProps({
modelValue: { type: Boolean, default: false },
ownerId: { type: String, default: '' },
initialName: { type: String, default: '' }
});
const emit = defineEmits(['update:modelValue', 'created']);
const toast = useToast();
const tenantStore = useTenantStore();
const visible = ref(props.modelValue);
watch(() => props.modelValue, (v) => { visible.value = v; });
watch(visible, (v) => emit('update:modelValue', v));
const form = ref({
name: '',
default_value: null,
notes: ''
});
const saving = ref(false);
watch(() => props.modelValue, (v) => {
if (v) {
form.value = {
name: props.initialName || '',
default_value: null,
notes: ''
};
}
});
const canSave = () => !!form.value.name?.trim();
async function onSave() {
if (!canSave()) return;
const ownerId = props.ownerId || (await supabase.auth.getUser()).data?.user?.id;
const tid = tenantStore.activeTenantId || tenantStore.tenantId;
if (!ownerId || !tid) {
toast.add({ severity: 'error', summary: 'Sem contexto', detail: 'Owner ou tenant ausentes.', life: 3500 });
return;
}
saving.value = true;
try {
const payload = {
owner_id: ownerId,
tenant_id: tid,
name: form.value.name.trim().slice(0, 120),
default_value: form.value.default_value != null ? Number(form.value.default_value) : null,
notes: form.value.notes?.trim().slice(0, 500) || null,
active: true
};
const { data, error } = await supabase.from('insurance_plans').insert(payload).select().single();
if (error) throw error;
toast.add({ severity: 'success', summary: 'Convênio criado', life: 2200 });
emit('created', data);
visible.value = false;
} catch (e) {
toast.add({ severity: 'error', summary: 'Falha ao criar convênio', detail: e?.message || 'Erro inesperado', life: 4000 });
} finally {
saving.value = false;
}
}
</script>
<template>
<Dialog
v-model:visible="visible"
modal
:draggable="false"
:closable="!saving"
header="Novo convênio"
class="w-[94vw] max-w-md"
pt:mask:class="backdrop-blur-sm"
>
<div class="flex flex-col gap-3 pt-1">
<FloatLabel variant="on">
<InputText id="ins-name" v-model="form.name" class="w-full" autofocus maxlength="120" />
<label for="ins-name">Nome do convênio *</label>
</FloatLabel>
<FloatLabel variant="on">
<InputNumber
id="ins-value"
v-model="form.default_value"
mode="currency"
currency="BRL"
locale="pt-BR"
:min="0"
:max="999999"
class="w-full"
/>
<label for="ins-value">Valor base sugerido (opcional)</label>
</FloatLabel>
<FloatLabel variant="on">
<Textarea id="ins-notes" v-model="form.notes" class="w-full" rows="2" autoResize maxlength="500" />
<label for="ins-notes">Observações (opcional)</label>
</FloatLabel>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" :disabled="saving" @click="visible = false" />
<Button
label="Criar convênio"
icon="pi pi-check"
:loading="saving"
:disabled="!canSave()"
class="rounded-full"
@click="onSave"
/>
</template>
</Dialog>
</template>
@@ -0,0 +1,154 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/agenda/components/ServiceQuickCreateDialog.vue
| Data: 2026-05-04
|
| Mini-dialog pra cadastrar um serviço SEM sair do AgendaEventDialog.
| Pattern: usuário agendando, percebe que falta o serviço no catálogo,
| clica "+", preenche nome/duração/valor, salva o emit `created` retorna
| o id pra que o parent pré-selecione no select de serviços.
|
| Campos mínimos (obrigatórios no schema):
| name, price, owner_id, tenant_id
| Opcionais úteis:
| duration_min, description
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, watch } from 'vue';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
const props = defineProps({
modelValue: { type: Boolean, default: false },
ownerId: { type: String, default: '' },
initialName: { type: String, default: '' }
});
const emit = defineEmits(['update:modelValue', 'created']);
const toast = useToast();
const tenantStore = useTenantStore();
const visible = ref(props.modelValue);
watch(() => props.modelValue, (v) => { visible.value = v; });
watch(visible, (v) => emit('update:modelValue', v));
const form = ref({
name: '',
price: null,
duration_min: 50,
description: ''
});
const saving = ref(false);
watch(() => props.modelValue, (v) => {
if (v) {
form.value = {
name: props.initialName || '',
price: null,
duration_min: 50,
description: ''
};
}
});
const canSave = () => !!form.value.name?.trim() && form.value.price != null && Number(form.value.price) >= 0;
async function onSave() {
if (!canSave()) return;
const ownerId = props.ownerId || (await supabase.auth.getUser()).data?.user?.id;
const tid = tenantStore.activeTenantId || tenantStore.tenantId;
if (!ownerId || !tid) {
toast.add({ severity: 'error', summary: 'Sem contexto', detail: 'Owner ou tenant ausentes.', life: 3500 });
return;
}
saving.value = true;
try {
const payload = {
owner_id: ownerId,
tenant_id: tid,
name: form.value.name.trim().slice(0, 120),
price: Number(form.value.price),
duration_min: form.value.duration_min ? Number(form.value.duration_min) : null,
description: form.value.description?.trim().slice(0, 500) || null,
active: true
};
const { data, error } = await supabase.from('services').insert(payload).select().single();
if (error) throw error;
toast.add({ severity: 'success', summary: 'Serviço criado', life: 2200 });
emit('created', data);
visible.value = false;
} catch (e) {
toast.add({ severity: 'error', summary: 'Falha ao criar serviço', detail: e?.message || 'Erro inesperado', life: 4000 });
} finally {
saving.value = false;
}
}
</script>
<template>
<Dialog
v-model:visible="visible"
modal
:draggable="false"
:closable="!saving"
header="Novo serviço"
class="w-[94vw] max-w-md"
pt:mask:class="backdrop-blur-sm"
>
<div class="flex flex-col gap-3 pt-1">
<FloatLabel variant="on">
<InputText id="svc-name" v-model="form.name" class="w-full" autofocus maxlength="120" />
<label for="svc-name">Nome do serviço *</label>
</FloatLabel>
<div class="grid grid-cols-2 gap-3">
<FloatLabel variant="on">
<InputNumber
id="svc-price"
v-model="form.price"
mode="currency"
currency="BRL"
locale="pt-BR"
:min="0"
:max="999999"
class="w-full"
/>
<label for="svc-price">Valor *</label>
</FloatLabel>
<FloatLabel variant="on">
<InputNumber
id="svc-duration"
v-model="form.duration_min"
:min="0"
:max="600"
:step="5"
suffix=" min"
class="w-full"
/>
<label for="svc-duration">Duração</label>
</FloatLabel>
</div>
<FloatLabel variant="on">
<Textarea id="svc-desc" v-model="form.description" class="w-full" rows="2" autoResize maxlength="500" />
<label for="svc-desc">Descrição (opcional)</label>
</FloatLabel>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" :disabled="saving" @click="visible = false" />
<Button
label="Criar serviço"
icon="pi pi-check"
:loading="saving"
:disabled="!canSave()"
class="rounded-full"
@click="onSave"
/>
</template>
</Dialog>
</template>
@@ -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
};
}
+9
View File
@@ -37,6 +37,15 @@ export default {
component: () => import('@/layout/melissa/MelissaLayout.vue')
},
// Preview do AgendaEventDialog V2 (A66 sub-sessão 2). Iteração
// visual antes de migrar consumers (sub-sessão 3). Pública pra
// facilitar dev — remover quando V2 estabilizar.
{
path: 'preview/agenda-dialog-v2',
name: 'PreviewAgendaDialogV2',
component: () => import('@/views/pages/preview/AgendaDialogV2Preview.vue')
},
// 404
{
path: 'pages/notfound',
@@ -0,0 +1,308 @@
<!--
|--------------------------------------------------------------------------
| Preview de AgendaEventDialogV2 com fixtures.
| Rota: /preview/agenda-dialog-v2
| Uso: visualizar e iterar o template V2 sem subir pra produção.
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, reactive } from 'vue';
import AgendaEventDialogV2 from '@/features/agenda/components/AgendaEventDialogV2.vue';
const open = ref(false);
const scenario = ref('novo-vazio');
// ── Fixtures ──────────────────────────────────────────────
const commitmentOptions = [
{ id: 'cm-sessao', name: 'Sessão', description: 'Atendimento clínico padrão', native_type: 'session', bg_color: '6366f1', fields: [] },
{ id: 'cm-bloqueio', name: 'Bloqueio', description: 'Indisponibilidade', native_type: 'bloqueio', bg_color: 'ef4444', fields: [] },
{ id: 'cm-supervisao', name: 'Supervisão', description: 'Encontro com supervisor', bg_color: 'f59e0b', fields: [] },
{ id: 'cm-evento', name: 'Evento', description: 'Reunião, palestra, etc.', bg_color: '10b981', fields: [] }
];
const workRules = [
{ dia_semana: 1, hora_inicio: '08:00', hora_fim: '18:00', ativo: true },
{ dia_semana: 2, hora_inicio: '08:00', hora_fim: '18:00', ativo: true },
{ dia_semana: 3, hora_inicio: '08:00', hora_fim: '18:00', ativo: true },
{ dia_semana: 4, hora_inicio: '08:00', hora_fim: '18:00', ativo: true },
{ dia_semana: 5, hora_inicio: '08:00', hora_fim: '17:00', ativo: true }
];
const agendaSettings = {
session_duration_min: 50,
session_break_min: 10,
slot_mode: 'fixed',
online_ativo: true
};
const allEvents = [];
const pausasSemanais = [];
const blockedDates = [];
// Cenários
const scenarios = {
'novo-vazio': {
label: 'Novo (vazio)',
eventRow: null,
presetCommitmentId: null
},
'novo-sessao': {
label: 'Novo (preset Sessão)',
eventRow: null,
presetCommitmentId: 'cm-sessao'
},
'edit-avulsa': {
label: 'Editar avulsa',
eventRow: {
id: 'evt-edit-1',
commitment_id: 'cm-sessao',
paciente_id: 'p-1',
paciente_nome: 'Marina Souza',
paciente_status: 'Ativo',
paciente_avatar: null,
inicio_em: '2026-05-09T14:00:00',
fim_em: '2026-05-09T14:50:00',
modalidade: 'presencial',
status: 'agendado',
price: 200,
observacoes: 'Sessão de retorno',
recurrence_id: null,
serie_id: null
},
presetCommitmentId: null
},
'edit-serie': {
label: 'Editar série',
eventRow: {
id: 'evt-serie-1',
commitment_id: 'cm-sessao',
paciente_id: 'p-2',
paciente_nome: 'Carlos Mendes',
paciente_status: 'Ativo',
paciente_avatar: null,
inicio_em: '2026-05-12T10:00:00',
fim_em: '2026-05-12T10:50:00',
modalidade: 'online',
status: 'agendado',
price: 220,
recurrence_id: 'rec-1',
serie_id: 'rec-1',
serie_dia_semana: 2,
serie_hora: '10:00'
},
presetCommitmentId: null
},
'paciente-inativo': {
label: 'Paciente inativo (lock)',
eventRow: {
id: 'evt-inativo-1',
commitment_id: 'cm-sessao',
paciente_id: 'p-3',
paciente_nome: 'João Pereira',
paciente_status: 'Inativo',
inicio_em: '2026-06-01T15:00:00',
fim_em: '2026-06-01T15:50:00',
modalidade: 'presencial',
status: 'agendado'
},
presetCommitmentId: null
}
};
const dialogProps = ref({
eventRow: null,
presetCommitmentId: null
});
function abrir(key) {
scenario.value = key;
const sc = scenarios[key];
dialogProps.value = {
eventRow: sc.eventRow,
presetCommitmentId: sc.presetCommitmentId
};
open.value = true;
}
// ── Logs de events ───────────────────────────────────────
const log = reactive({ entries: [] });
function pushLog(label, payload) {
log.entries.unshift({
ts: new Date().toLocaleTimeString('pt-BR'),
label,
payload: JSON.stringify(payload, null, 2)
});
if (log.entries.length > 8) log.entries.pop();
}
function onSave(payload) { pushLog('save', payload); }
function onDelete(payload) { pushLog('delete', payload); }
function onUpdateSeries(payload) { pushLog('updateSeriesEvent', payload); }
function onEditSeriesOccurrence(payload) { pushLog('editSeriesOccurrence', payload); }
</script>
<template>
<div class="aev2-preview-page">
<header class="aev2-preview-header">
<div>
<h1 class="text-xl font-bold">AgendaEventDialogV2 Preview</h1>
<p class="text-sm opacity-70">A66 sub-sessão 2. Iterar visual antes de migrar consumers (sub-sessão 3).</p>
</div>
<div class="text-xs opacity-60">
Cenário atual: <code>{{ scenario }}</code>
</div>
</header>
<div class="aev2-preview-grid">
<section>
<h2 class="aev2-preview-section-title">Cenários</h2>
<div class="aev2-preview-buttons">
<button
v-for="(sc, key) in scenarios"
:key="key"
type="button"
class="aev2-preview-btn"
:class="{ 'aev2-preview-btn--active': scenario === key }"
@click="abrir(key)"
>
{{ sc.label }}
</button>
</div>
</section>
<section>
<h2 class="aev2-preview-section-title">Eventos emitidos</h2>
<div class="aev2-preview-log">
<div v-if="!log.entries.length" class="opacity-50 text-sm">
Abra o dialog e dispare ações pra ver os emits aqui.
</div>
<div v-else>
<div v-for="(e, i) in log.entries" :key="i" class="aev2-preview-log-entry">
<div class="aev2-preview-log-meta">
<span class="font-mono text-xs opacity-60">{{ e.ts }}</span>
<span class="aev2-preview-log-label">{{ e.label }}</span>
</div>
<pre class="aev2-preview-log-payload">{{ e.payload }}</pre>
</div>
</div>
</div>
</section>
</div>
<AgendaEventDialogV2
v-model="open"
:event-row="dialogProps.eventRow"
:preset-commitment-id="dialogProps.presetCommitmentId"
owner-id="owner-preview"
tenant-id="tenant-preview"
:commitment-options="commitmentOptions"
:work-rules="workRules"
:agenda-settings="agendaSettings"
:all-events="allEvents"
:pausas-semanais="pausasSemanais"
:blocked-dates="blockedDates"
@save="onSave"
@delete="onDelete"
@update-series-event="onUpdateSeries"
@edit-series-occurrence="onEditSeriesOccurrence"
/>
</div>
</template>
<style scoped>
.aev2-preview-page {
max-width: 1200px;
margin: 0 auto;
padding: 2rem 1.5rem;
color: var(--text-color);
}
.aev2-preview-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 1rem;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--surface-border);
}
.aev2-preview-grid {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 1.5rem;
}
@media (max-width: 800px) {
.aev2-preview-grid { grid-template-columns: 1fr; }
}
.aev2-preview-section-title {
font-size: .8rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .04em;
opacity: .7;
margin-bottom: .75rem;
}
.aev2-preview-buttons {
display: flex;
flex-direction: column;
gap: .35rem;
}
.aev2-preview-btn {
text-align: left;
border: 1px solid var(--surface-border);
background: var(--surface-card);
color: var(--text-color);
padding: .65rem .85rem;
border-radius: 10px;
font-size: .85rem;
cursor: pointer;
transition: all .15s;
}
.aev2-preview-btn:hover {
border-color: var(--p-primary-500, #6366f1);
transform: translateX(2px);
}
.aev2-preview-btn--active {
background: var(--p-primary-500, #6366f1);
color: #fff;
border-color: var(--p-primary-500, #6366f1);
font-weight: 600;
}
.aev2-preview-log {
background: var(--surface-card);
border: 1px solid var(--surface-border);
border-radius: 10px;
padding: 1rem;
max-height: 480px;
overflow-y: auto;
}
.aev2-preview-log-entry {
border-top: 1px solid var(--surface-border);
padding: .5rem 0;
}
.aev2-preview-log-entry:first-child { border-top: 0; padding-top: 0; }
.aev2-preview-log-meta {
display: flex;
align-items: center;
gap: .5rem;
margin-bottom: .25rem;
}
.aev2-preview-log-label {
background: var(--p-primary-500, #6366f1);
color: #fff;
padding: 1px 7px;
border-radius: 4px;
font-size: .7rem;
font-weight: 600;
}
.aev2-preview-log-payload {
font-family: ui-monospace, monospace;
font-size: .72rem;
background: var(--surface-100);
padding: .5rem;
border-radius: 6px;
overflow-x: auto;
margin: 0;
white-space: pre-wrap;
word-break: break-all;
}
</style>