a7f6bcbe66
- useTenantDb composable + lib/supabase/tenantClient (tenantDb/tenantSchemaName)
- tenantStore: getters activeTenantSlug/activeTenantSchema; my_tenants() RPC
passa a devolver slug+nome (migration 07)
- codemod scripts/codemod-tenant-db.py: supabase.from('<84 tabelas + 6 views
tenant>') -> tenantDb().from(...) em 139 arquivos (777 chamadas), remove
.eq('tenant_id') das cadeias tenant (173)
- passada manual (4 agentes): remove tenant_id de payloads insert/upsert/update,
selects, .or/.is de defaults; onConflict ajustado pros uniques sem tenant_id
(singletons usam 'singleton'); realtime de tabelas tenant aponta pro schema
do tenant ativo; repos dropam tenant_id defensivamente de payloads externos
- agendaSelects: tenant_id fora do AGENDA_EVENT_SELECT (quebraria PostgREST)
- zero embeds cross-schema (todos FK embeds sao tenant->tenant ou global->global)
- build de producao passa; 67 .js checados
Pendente (fora do escopo F3, sao cross-tenant/anon -> F4/F6):
- AgendadorPublicoPage (anon, resolve tenant por link_slug)
- Saas{Feriados,NotificationTemplates,DocumentTemplates,Whatsapp}Page
(gerenciam defaults do sistema / views cross-tenant)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
461 lines
24 KiB
JavaScript
461 lines
24 KiB
JavaScript
/*
|
|
|--------------------------------------------------------------------------
|
|
| 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 { tenantDb } from '@/lib/supabase/tenantClient';
|
|
import { labelStatusSessao } from './agendaEventHelpers';
|
|
|
|
const EVENTO_TIPO_SESSAO = 'sessao';
|
|
|
|
export function useAgendaEventActions({
|
|
composer,
|
|
commitmentItems,
|
|
servicePickerSel,
|
|
selectedPlanService,
|
|
saveCommitmentItems,
|
|
// chargeMode (Opção C1, 2026-05-13): ref string com modo de cobrança.
|
|
// Valores: 'none' | 'session' (avulsa) | 'package' | 'per_session' (recorrente).
|
|
// O emit('save') leva chargeMode no payload; handler em useMelissaAgenda
|
|
// decide o que criar (financial_record | billing_contract | N events+records).
|
|
// Substituiu o boolean gerarCobrancaAoSalvar.
|
|
chargeMode,
|
|
// packageStyle (2026-05-14): só relevante em chargeMode='package'.
|
|
// Valores: 'upfront' (cria 1 financial_record total + materializa 1ª ocorrência)
|
|
// | 'saldo' (só billing_contract, sem financial_record imediato — Cliniko).
|
|
packageStyle,
|
|
// paymentMethod (refatorado 2026-05-16): forma de recebimento quando
|
|
// avulsa+session OU pacote+upfront. Valores: 'link' (Asaas, status pending)
|
|
// | 'pix' | 'dinheiro' | 'deposito' | 'cartao_maquininha'. Status do
|
|
// record é controlado pelo markPaidNow abaixo, não pela forma.
|
|
paymentMethod,
|
|
// markPaidNow (refatorado 2026-05-16): boolean. Quando true E método !== 'link',
|
|
// handler marca o financial_record como paid (paciente pagou na hora).
|
|
// Quando false, record nasce pending independente do método.
|
|
markPaidNow,
|
|
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
|
|
// Fase 5 (2026-05-14): pra realizado/faltou/cancelado, emit
|
|
// `updateSeriesEvent` pro parent abrir o AgendaStatusChangeConfirmDialog
|
|
// (com regras de exceção, saldo de pacote, etc). Sem confirm.require
|
|
// aqui — o dialog do parent é a fonte canônica.
|
|
// Pra remarcado mantém path antigo (confirm.require simples).
|
|
// Se user cancelar o dialog: parent chama onReject pra reverter o form.
|
|
// ────────────────────────────────────────────────────────────────────
|
|
watch(
|
|
() => composer.form.value?.status,
|
|
async (newVal, oldVal) => {
|
|
if (_skipStatusWatch.value) return;
|
|
if (!composer.isEdit.value || !composer.form.value?.id) return;
|
|
|
|
const isStatusComDialog = ['realizado', 'faltou', 'cancelado'].includes(newVal);
|
|
const isRemarcado = newVal === 'remarcado';
|
|
if (!isStatusComDialog && !isRemarcado) return;
|
|
|
|
_prevStatus.value = oldVal;
|
|
|
|
// Fase 5: emit pro parent abrir AgendaStatusChangeConfirmDialog.
|
|
// Parent decide o que fazer e chama onReject() se user cancelar.
|
|
if (isStatusComDialog) {
|
|
const formId = composer.form.value.id;
|
|
const isVirtual = !!composer.form.value.is_occurrence || (typeof formId === 'string' && formId.startsWith('rec::'));
|
|
emit('updateSeriesEvent', {
|
|
id: isVirtual ? null : formId,
|
|
status: newVal,
|
|
recurrence_date:
|
|
composer.form.value.recurrence_date ||
|
|
composer.form.value.original_date ||
|
|
String(composer.form.value.inicio_em || '').slice(0, 10),
|
|
inicio_em: composer.form.value.inicio_em,
|
|
fim_em: composer.form.value.fim_em,
|
|
is_virtual: isVirtual,
|
|
// Form completo — handler usa pra resolver recurrence_id, billing_contract_id, etc
|
|
row: { ...composer.form.value },
|
|
// Callback pra reverter status no form se user cancelar o dialog do parent.
|
|
// _skipStatusWatch evita loop recursivo no watcher.
|
|
onReject: () => {
|
|
_skipStatusWatch.value = true;
|
|
composer.form.value.status = _prevStatus.value;
|
|
Promise.resolve().then(() => {
|
|
_skipStatusWatch.value = false;
|
|
});
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Path legacy pra 'remarcado': confirm.require simples + UPDATE direto.
|
|
confirm.require({
|
|
header: 'Remarcar sessão',
|
|
message: 'Tem certeza que deseja marcar esta sessão para remarcação? O status será salvo imediatamente.',
|
|
icon: 'pi pi-refresh',
|
|
acceptLabel: 'Sim, confirmar',
|
|
rejectLabel: 'Não',
|
|
acceptSeverity: 'warn',
|
|
accept: async () => {
|
|
try {
|
|
const formId = composer.form.value.id;
|
|
const isVirtual = !!composer.form.value.is_occurrence || (typeof formId === 'string' && formId.startsWith('rec::'));
|
|
|
|
if (isVirtual) {
|
|
emit('updateSeriesEvent', {
|
|
id: null,
|
|
status: newVal,
|
|
recurrence_date: composer.form.value.recurrence_date || composer.form.value.original_date || String(composer.form.value.inicio_em || '').slice(0, 10),
|
|
inicio_em: composer.form.value.inicio_em,
|
|
fim_em: composer.form.value.fim_em,
|
|
is_virtual: true,
|
|
row: { ...composer.form.value }
|
|
});
|
|
toast.add({ severity: 'success', summary: 'Status atualizado', detail: `Sessão marcada como ${labelStatusSessao(newVal)}.`, life: 3000 });
|
|
return;
|
|
}
|
|
const { data, error } = await tenantDb().from('agenda_eventos').update({ status: newVal }).eq('id', formId).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 = tenantDb().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,
|
|
// _occurrenceMode: flag pra distinguir save do 2o dialog empilhado
|
|
// (editar UMA ocorrencia) do save do dialog pai. Handler decide qual
|
|
// dialog fechar — sem isso, fechava sempre o pai. 2026-05-12.
|
|
_occurrenceMode: !!props.occurrenceMode,
|
|
// chargeMode (Opção C1, 2026-05-13): handler decide entre criar
|
|
// financial_record (avulsa+session), billing_contract (recorrente+package)
|
|
// ou materializar N ocorrências + N records (recorrente+per_session).
|
|
// UI no .vue garante valores válidos por modo.
|
|
chargeMode: chargeMode?.value ?? 'none',
|
|
// packageStyle (2026-05-14): handler em useMelissaAgenda usa pra
|
|
// decidir entre upfront (1 record total + materializa 1ª) ou
|
|
// saldo (só contrato).
|
|
packageStyle: packageStyle?.value ?? 'upfront',
|
|
// paymentMethod + markPaidNow (refatorado 2026-05-16): substituem
|
|
// o antigo paymentSettlement. Handler aplica payment_method (sempre)
|
|
// e status=paid+paid_at apenas quando markPaidNow=true && method!='link'.
|
|
paymentMethod: paymentMethod?.value ?? 'link',
|
|
markPaidNow: markPaidNow?.value === true,
|
|
// 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
|
|
};
|
|
}
|