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>
This commit is contained in:
@@ -652,16 +652,42 @@ function fecharEvento() {
|
||||
// ── Actions do MelissaEventoPanel ──────────────────────────────
|
||||
// updateStatus: muda status no DB e refetcha agenda. Pattern espelha
|
||||
// AgendaTerapeutaPage (sem optimistic update por simplicidade no MVP).
|
||||
//
|
||||
// Quando `ev` é ocorrência VIRTUAL de recorrência (id `rec::...` sem row real),
|
||||
// delega pro M.onUpdateSeriesEvent que materializa antes do UPDATE — sem isso
|
||||
// PostgreSQL recusa o UPDATE com "invalid input syntax for type uuid".
|
||||
async function updateEventoStatus(novoStatus, msgSucesso) {
|
||||
const ev = eventoSelecionado.value;
|
||||
if (!ev?.id || eventoBusy.value) return;
|
||||
eventoBusy.value = true;
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.update({ status: novoStatus })
|
||||
.eq('id', ev.id);
|
||||
if (error) throw error;
|
||||
const isVirtual =
|
||||
!!ev.is_occurrence ||
|
||||
(typeof ev.id === 'string' && ev.id.startsWith('rec::'));
|
||||
|
||||
if (isVirtual) {
|
||||
await M.onUpdateSeriesEvent({
|
||||
id: null,
|
||||
status: novoStatus,
|
||||
recurrence_date:
|
||||
ev.recurrence_date ||
|
||||
ev.original_date ||
|
||||
String(ev.inicio_em || '').slice(0, 10),
|
||||
inicio_em: ev.inicio_em,
|
||||
fim_em: ev.fim_em,
|
||||
is_virtual: true,
|
||||
// Passa o ev completo — sem isso o handler depende de
|
||||
// dialogEventRow.value (que está vazio quando o user clica
|
||||
// direto no evento do FC sem abrir o dialog antes).
|
||||
row: ev
|
||||
});
|
||||
} else {
|
||||
const { error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.update({ status: novoStatus })
|
||||
.eq('id', ev.id);
|
||||
if (error) throw error;
|
||||
}
|
||||
toast.add({ severity: 'success', summary: msgSucesso, life: 2200 });
|
||||
// Refetch:
|
||||
// - M.refetch() alimenta a Agenda (FullCalendar + ocorrências virtuais)
|
||||
|
||||
@@ -321,8 +321,11 @@ async function revertRecordPaid(record) {
|
||||
}
|
||||
|
||||
// Handler de mutacao de status (Realizada / Falta / Cancelar)
|
||||
// Passa a row inteira pro composable porque pode ser ocorrência virtual de
|
||||
// recorrência (id `rec::ruleId::date`) — nesse caso o composable materializa
|
||||
// uma linha real antes de aplicar o status (UPDATE em id virtual quebra).
|
||||
async function updateSessionStatus(ev, novoStatus, msg) {
|
||||
const result = await sessionsHook.updateStatus(ev.id, novoStatus);
|
||||
const result = await sessionsHook.updateStatus(ev, novoStatus);
|
||||
if (result.ok) {
|
||||
toast.add({ severity: 'success', summary: msg, life: 2200 });
|
||||
} else {
|
||||
@@ -539,6 +542,21 @@ async function onSessaoDialogSave(payload) {
|
||||
]);
|
||||
}
|
||||
}
|
||||
// Mudança de status numa ocorrência (cancelado/remarcado/etc) — delega pro
|
||||
// handler do composable que SABE materializar ocorrência virtual antes de
|
||||
// aplicar o status. Sem isso o UPDATE em id virtual quebra ("invalid input
|
||||
// syntax for type uuid"). Espelha o wire-up de MelissaLayout/AgendaTerapeuta.
|
||||
async function onSessaoDialogUpdateSeries(payload) {
|
||||
if (typeof melissaAgenda?.onUpdateSeriesEvent === 'function') {
|
||||
await melissaAgenda.onUpdateSeriesEvent(payload);
|
||||
}
|
||||
if (props.patientId) {
|
||||
await Promise.all([
|
||||
sessionsHook.load(props.patientId),
|
||||
recorrenciasHook.load(props.patientId)
|
||||
]);
|
||||
}
|
||||
}
|
||||
async function onSessaoDialogDelete(payload) {
|
||||
if (typeof melissaAgenda?.onDialogDelete === 'function') {
|
||||
await melissaAgenda.onDialogDelete(payload);
|
||||
@@ -2296,7 +2314,7 @@ onBeforeUnmount(() => {
|
||||
:lock-patient="true"
|
||||
@save="onSessaoDialogSave"
|
||||
@delete="onSessaoDialogDelete"
|
||||
@updateSeriesEvent="onSessaoDialogSave"
|
||||
@updateSeriesEvent="onSessaoDialogUpdateSeries"
|
||||
@editSeriesOccurrence="onSessaoDialogSave"
|
||||
>
|
||||
<template #headerLeft>
|
||||
|
||||
@@ -806,7 +806,11 @@ function _buildHandlers(deps) {
|
||||
}
|
||||
|
||||
// ── onUpdateSeriesEvent — mudança de status numa ocorrência ──
|
||||
async function onUpdateSeriesEvent({ id, status, recurrence_date, inicio_em, fim_em, is_virtual }) {
|
||||
//
|
||||
// `row` opcional: row completa quando o chamador NÃO abriu o dialog antes
|
||||
// (MelissaEventoPanel clica direto no evento → não há dialogEventRow ainda).
|
||||
// Sem isso, recurrence_id/patient_id caem pra null e criavam row órfã.
|
||||
async function onUpdateSeriesEvent({ id, status, recurrence_date, inicio_em, fim_em, is_virtual, row: callerRow }) {
|
||||
try {
|
||||
if (id) {
|
||||
await update(id, { status });
|
||||
@@ -814,20 +818,29 @@ function _buildHandlers(deps) {
|
||||
}
|
||||
if (!is_virtual || !inicio_em) return;
|
||||
|
||||
const rid = dialogEventRow.value?.recurrence_id ?? dialogEventRow.value?.serie_id ?? null;
|
||||
// Prioridade: row passado pelo chamador > dialogEventRow > vazio.
|
||||
// dialogEventRow só está populado se o user abriu o dialog antes.
|
||||
const row = callerRow || dialogEventRow.value || {};
|
||||
const rid = row.recurrence_id ?? row.serie_id ?? dialogEventRow.value?.recurrence_id ?? dialogEventRow.value?.serie_id ?? null;
|
||||
const rDate = recurrence_date || inicio_em?.slice(0, 10);
|
||||
|
||||
const { data: existing } = await supabase
|
||||
if (!rid) {
|
||||
toast.add({ severity: 'warn', summary: 'Erro', detail: 'Não foi possível identificar a regra de recorrência desta ocorrência.', life: 4000 });
|
||||
return;
|
||||
}
|
||||
|
||||
// .is() pra null seria inválido aqui — rid já validado acima.
|
||||
const { data: existing, error: exErr } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('id')
|
||||
.eq('recurrence_id', rid)
|
||||
.eq('recurrence_date', rDate)
|
||||
.maybeSingle();
|
||||
if (exErr) throw exErr;
|
||||
|
||||
if (existing?.id) {
|
||||
await update(existing.id, { status });
|
||||
} else {
|
||||
const row = dialogEventRow.value || {};
|
||||
await create({
|
||||
owner_id: ownerId.value,
|
||||
tenant_id: clinicTenantId.value,
|
||||
@@ -841,6 +854,7 @@ function _buildHandlers(deps) {
|
||||
titulo: row.titulo || 'Sessão',
|
||||
patient_id: row.patient_id || row.paciente_id || null,
|
||||
determined_commitment_id: row.determined_commitment_id || null,
|
||||
modalidade: row.modalidade || 'presencial',
|
||||
price: row.price ?? null
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,13 +20,16 @@
|
||||
*
|
||||
* Sem auth/tenant: retorna [] silencioso (UI segue funcional).
|
||||
*
|
||||
* NÃO inclui ocorrências virtuais de recorrência (loadAndExpand) pra simplificar
|
||||
* o preview. Adicionar quando promover Melissa pra produção.
|
||||
* Inclui ocorrências virtuais de recorrência (via useRecurrence.loadAndExpand)
|
||||
* mescladas com linhas reais — assim widgets ("Hoje", mini-cal, "Ver todas")
|
||||
* mostram sessões futuras de séries semanais/quinzenais mesmo antes da
|
||||
* materialização. Falha do expand não bloqueia: fallback silencioso pra reais.
|
||||
*/
|
||||
import { ref, watch, onMounted, computed } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useTenantStore } from '@/stores/tenantStore';
|
||||
import { useMelissaCacheStore, MELISSA_CACHE_TTL } from '@/stores/melissaCacheStore';
|
||||
import { useRecurrence } from '@/features/agenda/composables/useRecurrence';
|
||||
|
||||
// ── Cores por tipo/status (consistente com o resto do Melissa) ──
|
||||
function pickColor(tipo, status) {
|
||||
@@ -48,29 +51,43 @@ function isoToDecimalHour(iso) {
|
||||
}
|
||||
|
||||
function normalizeEvent(r) {
|
||||
const pacNome = r.patients?.nome_completo || '';
|
||||
// Linhas reais (joined query) trazem `patients.nome_completo`; ocorrências
|
||||
// virtuais (loadAndExpand) trazem `paciente_nome`/`patient_name`. Aceita os 3
|
||||
// pra que a mesma normalização sirva pros dois tipos sem perder o rótulo.
|
||||
const pacNome = r.patients?.nome_completo || r.paciente_nome || r.patient_name || '';
|
||||
const pacStatus = r.patients?.status || r.paciente_status || '';
|
||||
return {
|
||||
id: r.id,
|
||||
tipo: r.tipo || 'sessao',
|
||||
status: r.status || '',
|
||||
titulo: r.titulo || '',
|
||||
titulo: r.titulo || r.titulo_custom || '',
|
||||
patient_id: r.patient_id || null,
|
||||
pacienteNome: pacNome,
|
||||
// Status do paciente — usado pelo MelissaAgenda pra marcar visualmente
|
||||
// eventos de paciente Inativo/Arquivado (borda tracejada + opacidade).
|
||||
paciente_status: pacStatus,
|
||||
modalidade: r.modalidade || '',
|
||||
descricao: r.observacoes || '',
|
||||
color: pickColor(r.tipo, r.status),
|
||||
label: pacNome || r.titulo || '—',
|
||||
label: pacNome || r.titulo || r.titulo_custom || '—',
|
||||
inicio_em: r.inicio_em,
|
||||
fim_em: r.fim_em,
|
||||
startH: isoToDecimalHour(r.inicio_em),
|
||||
endH: isoToDecimalHour(r.fim_em),
|
||||
dateKey: String(r.inicio_em || '').slice(0, 10),
|
||||
price: r.price != null ? Number(r.price) : 0,
|
||||
billed: !!r.billed
|
||||
billed: !!r.billed,
|
||||
// Flag pra consumidores diferenciarem materializada vs virtual de recorrência
|
||||
// (UI pode optar por badge, click handler pode materializar antes de editar).
|
||||
is_occurrence: !!r.is_occurrence,
|
||||
recurrence_id: r.recurrence_id ?? null
|
||||
};
|
||||
}
|
||||
|
||||
// ── Helper interno: garante uid + tenant + faz a query ──
|
||||
// Carrega linhas reais de agenda_eventos no range + expande recorrências
|
||||
// virtuais (loadAndExpand). Falha da expansão não bloqueia o fetch: cai pra
|
||||
// só-reais com aviso no console — UI continua funcional.
|
||||
async function _fetchRange(start, end) {
|
||||
const tenantStore = useTenantStore();
|
||||
const { data: userData } = await supabase.auth.getUser();
|
||||
@@ -82,9 +99,11 @@ async function _fetchRange(start, end) {
|
||||
|
||||
if (!userId || !tid) return [];
|
||||
|
||||
// Recurrence_id/date inclusos no select pra mergeWithStoredSessions dedupar
|
||||
// ocorrências já materializadas (sessões reais ganham precedência sobre virtuais).
|
||||
const { data, error } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('id, tipo, status, titulo, inicio_em, fim_em, modalidade, observacoes, patient_id, price, billed, patients!agenda_eventos_patient_id_fkey(nome_completo, status)')
|
||||
.select('id, tipo, status, titulo, inicio_em, fim_em, modalidade, observacoes, patient_id, price, billed, recurrence_id, recurrence_date, patients!agenda_eventos_patient_id_fkey(nome_completo, status)')
|
||||
.eq('owner_id', userId)
|
||||
.is('mirror_of_event_id', null)
|
||||
.gte('inicio_em', start.toISOString())
|
||||
@@ -92,7 +111,20 @@ async function _fetchRange(start, end) {
|
||||
.order('inicio_em', { ascending: true });
|
||||
|
||||
if (error) throw error;
|
||||
return (data || []).map(normalizeEvent);
|
||||
const realRows = data || [];
|
||||
|
||||
// Expansão de recorrências — falha silenciosa não derruba a UI.
|
||||
let virtualOccs = [];
|
||||
try {
|
||||
const { loadAndExpand } = useRecurrence();
|
||||
const expanded = await loadAndExpand(userId, start, end, realRows, tid);
|
||||
virtualOccs = expanded.filter((r) => r.is_occurrence);
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[useMelissaEventos] recurrence expand falhou:', e);
|
||||
}
|
||||
|
||||
return [...realRows, ...virtualOccs].map(normalizeEvent);
|
||||
}
|
||||
|
||||
// ── Range helpers ──────────────────────────────────────────────
|
||||
@@ -293,14 +325,33 @@ export function useMelissaTodasSessoesPaciente() {
|
||||
|
||||
const { data, error: err } = await supabase
|
||||
.from('agenda_eventos')
|
||||
.select('id, tipo, status, titulo, inicio_em, fim_em, modalidade, observacoes, patient_id, price, billed, patients!agenda_eventos_patient_id_fkey(nome_completo, status)')
|
||||
.select('id, tipo, status, titulo, inicio_em, fim_em, modalidade, observacoes, patient_id, price, billed, recurrence_id, recurrence_date, patients!agenda_eventos_patient_id_fkey(nome_completo, status)')
|
||||
.eq('owner_id', userId)
|
||||
.eq('patient_id', patientId)
|
||||
.is('mirror_of_event_id', null)
|
||||
.order('inicio_em', { ascending: false });
|
||||
|
||||
if (err) throw err;
|
||||
eventos.value = (data || []).map(normalizeEvent);
|
||||
const realRows = data || [];
|
||||
|
||||
// Expansão de recorrências do paciente — range padrão -6mo → +12mo
|
||||
// (cobre histórico recente + ~1 ano de série semanal/quinzenal futura).
|
||||
let virtualOccs = [];
|
||||
try {
|
||||
const { loadAndExpand } = useRecurrence();
|
||||
const now = new Date();
|
||||
const rangeStart = new Date(now.getFullYear(), now.getMonth() - 6, 1);
|
||||
const rangeEnd = new Date(now.getFullYear() + 1, now.getMonth(), 1);
|
||||
const expanded = await loadAndExpand(userId, rangeStart, rangeEnd, realRows, tid);
|
||||
virtualOccs = expanded.filter((r) => r.is_occurrence && r.patient_id === patientId);
|
||||
} catch (rec) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[useMelissaTodasSessoesPaciente] recurrence expand falhou:', rec);
|
||||
}
|
||||
|
||||
const merged = [...realRows, ...virtualOccs];
|
||||
merged.sort((a, b) => String(b.inicio_em).localeCompare(String(a.inicio_em)));
|
||||
eventos.value = merged.map(normalizeEvent);
|
||||
} catch (e) {
|
||||
error.value = e?.message || 'Erro ao carregar sessões';
|
||||
eventos.value = [];
|
||||
|
||||
Reference in New Issue
Block a user