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:
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 tá 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:00–14: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
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user