Files
agenciapsilmno/src/features/agenda/composables/useAgendaEventActions.js
T
Leonardo 39cf0178e6 agenda: expandir e materializar ocorrencias de recorrencia (cross-layout)
PROBLEMA 1 — recorrencias virtuais nao apareciam em listas de sessao
============================================================
Sistema cria 1 row real em agenda_eventos por recorrencia (a primeira
ocorrencia) + 1 regra em recurrence_rules. As N-1 sessoes seguintes sao
geradas em runtime via useRecurrence.loadAndExpand. AgendaTerapeutaPage e
AgendaClinicaPage ja usavam loadAndExpand, mas composables compartilhados
("Hoje", widget, prontuario, ver todas) so liam agenda_eventos direto —
serie semanal de 4 sessoes aparecia como 1.

Fix em 3 composables cross-layout:
- usePatientSessions.load — range padrao -6mo a +12mo, filtra virtuais
  por patient_id apos loadAndExpand. Cobre MelissaPaciente Tab Agenda +
  PatientProntuario legacy.
- useMelissaEventos._fetchRange — merge real + virtual no range visivel.
  Cobre widget "Hoje" (MelissaLayout), mini-cal, fallback standalone do
  MelissaAgenda. Falha do expand cai silencioso pra so-reais.
- useMelissaTodasSessoesPaciente.fetch — mesma logica do paciente, range
  -6mo a +12mo. Cobre "Ver todas as sessoes" do MelissaAgenda.

normalizeEvent agora aceita shape de virtual (paciente_nome/patient_name)
alem de joined query (patients.nome_completo). Expoe is_occurrence +
recurrence_id pra consumidores diferenciarem.

PROBLEMA 2 — UPDATE em id virtual quebra com "invalid input syntax for type uuid"
============================================================
Apos #1, ocorrencias virtuais aparecem na UI. Quando o user mudava status
(via botoes do MelissaEventoPanel, watcher do form.status no
AgendaEventDialog, ou botoes diretos no MelissaPaciente Tab Agenda), o
UPDATE caia direto no PostgreSQL com id "rec::ruleId::date" — sintaxe
invalida pra coluna UUID.

Materializacao em 4 caminhos:
- usePatientSessions.updateStatus(sessionOrId, status) — aceita row inteira
  agora. Se virtual, busca row real por recurrence_id+date, ou cria nova
  copiando campos da virtual (com status aplicado).
- useAgendaEventActions watcher do form.status — emit('updateSeriesEvent',
  { ..., row: form }) em vez de UPDATE direto. Parent materializa.
- MelissaLayout.updateEventoStatus — detecta virtual, delega pro
  M.onUpdateSeriesEvent passando row: ev (sem isso, dialogEventRow ficaria
  vazio porque user nao abriu o dialog antes — criava row orfa sem
  patient_id).
- MelissaPaciente — @updateSeriesEvent do dialog local aponta pro
  onSessaoDialogUpdateSeries (wrapper que delega pro composable que sabe
  materializar). Antes apontava pro save normal.

useMelissaAgenda.onUpdateSeriesEvent atualizado:
- aceita row opcional do chamador (prioridade > dialogEventRow > vazio).
- guard: aborta com toast se rid (recurrence_id) for null, em vez de
  criar row orfa.
- error check no .maybeSingle (antes ignorado — query falhando seguia pro
  insert e duplicava sessoes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 10:46:58 -03:00

424 lines
21 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 { 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 {
// Se o evento é ocorrência VIRTUAL de recorrência
// (id "rec::..." sem row real em agenda_eventos),
// delega pro parent — useMelissaAgenda.onUpdateSeriesEvent
// e AgendaTerapeutaPage.onUpdateSeriesEvent materializam
// a linha antes de aplicar status. Sem essa delegação,
// UPDATE direto em id virtual quebra com PostgreSQL
// "invalid input syntax for type uuid".
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, // sem row real
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,
// Form completo do dialog — handler usa pra resolver
// recurrence_id/patient_id sem depender de dialogEventRow.
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 supabase
.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 = 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
};
}