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:
Leonardo
2026-05-11 10:46:58 -03:00
parent 279b4f78e8
commit 39cf0178e6
6 changed files with 293 additions and 30 deletions
@@ -87,10 +87,46 @@ export function useAgendaEventActions({
acceptSeverity: isCancelar ? 'danger' : 'warn', acceptSeverity: isCancelar ? 'danger' : 'warn',
accept: async () => { accept: async () => {
try { 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 const { data, error } = await supabase
.from('agenda_eventos') .from('agenda_eventos')
.update({ status: newVal }) .update({ status: newVal })
.eq('id', composer.form.value.id) .eq('id', formId)
.select() .select()
.single(); .single();
if (error) throw error; if (error) throw error;
@@ -11,6 +11,7 @@
*/ */
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { useRecurrence } from '@/features/agenda/composables/useRecurrence';
export function usePatientSessions() { export function usePatientSessions() {
const sessions = ref([]); const sessions = ref([]);
@@ -19,6 +20,9 @@ export function usePatientSessions() {
const busy = ref(false); // mutations em curso (updateStatus etc) const busy = ref(false); // mutations em curso (updateStatus etc)
let _lastPatientId = null; let _lastPatientId = null;
// Instancia local — refs internos (rules/exceptions) ficam isolados deste consumidor.
const { loadAndExpand } = useRecurrence();
async function load(patientId) { async function load(patientId) {
_lastPatientId = patientId || null; _lastPatientId = patientId || null;
if (!patientId) { if (!patientId) {
@@ -29,14 +33,49 @@ export function usePatientSessions() {
error.value = ''; error.value = '';
sessions.value = []; sessions.value = [];
try { try {
// 1. Linhas reais — `recurrence_id`/`recurrence_date` inclusos pra
// mergeWithStoredSessions deduplicar virtuais de sessões já materializadas.
const { data, error: err } = await supabase const { data, error: err } = await supabase
.from('agenda_eventos') .from('agenda_eventos')
.select('id, inicio_em, fim_em, status, modalidade, tipo, titulo, titulo_custom, observacoes') .select('id, inicio_em, fim_em, status, modalidade, tipo, titulo, titulo_custom, observacoes, patient_id, recurrence_id, recurrence_date')
.eq('patient_id', patientId) .eq('patient_id', patientId)
.order('inicio_em', { ascending: false }) .order('inicio_em', { ascending: false })
.limit(100); .limit(100);
if (err) throw err; if (err) throw err;
sessions.value = data || []; const realRows = data || [];
// 2. Expande recorrências do owner + filtra só as deste paciente.
// Range default: 6 meses atrás → 12 meses à frente (cobre histórico
// recente + ~1 ano de séries semanais/quinzenais futuras). Sem expansão,
// sessão 1 aparece (materializada) mas as N-1 virtuais ficam invisíveis.
let virtualOccs = [];
try {
const { data: userData } = await supabase.auth.getUser();
const ownerId = userData?.user?.id || null;
if (ownerId) {
let tenantId = null;
try {
const { useTenantStore } = await import('@/stores/tenantStore');
tenantId = useTenantStore().activeTenantId || null;
} catch { /* sem tenant store — segue */ }
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(ownerId, rangeStart, rangeEnd, realRows, tenantId);
virtualOccs = expanded.filter((r) => r.is_occurrence && r.patient_id === patientId);
}
} catch (e) {
// Fallback silencioso — UI segue funcional só com sessões reais.
// eslint-disable-next-line no-console
console.warn('[usePatientSessions] recurrence expand falhou:', e);
}
// 3. Merge desc por inicio_em (mantém contrato do composable original).
const merged = [...realRows, ...virtualOccs];
merged.sort((a, b) => String(b.inicio_em).localeCompare(String(a.inicio_em)));
sessions.value = merged;
} catch (e) { } catch (e) {
error.value = e?.message || 'Falha ao carregar sessões.'; error.value = e?.message || 'Falha ao carregar sessões.';
sessions.value = []; sessions.value = [];
@@ -145,17 +184,96 @@ export function usePatientSessions() {
/** /**
* Atualiza o status de uma sessao (mutation). Recarrega a lista do paciente * Atualiza o status de uma sessao (mutation). Recarrega a lista do paciente
* ao final pra refletir o novo estado nos computeds derivados. * ao final pra refletir o novo estado nos computeds derivados.
*
* Aceita string (UUID legado) OU a row inteira da sessão. Quando vier a row
* e ela for ocorrência virtual (is_occurrence=true, id `rec::ruleId::date`),
* MATERIALIZA primeiro: cria/encontra a linha real em agenda_eventos com
* recurrence_id+recurrence_date apontando pra regra, depois aplica o status.
* Sem isso o UPDATE falha com "invalid input syntax for type uuid" porque
* o id virtual nunca existiu no banco. Espelha o pattern de
* useMelissaAgenda.onUpdateSeriesEvent (L808-850).
*
* Retorna {ok: true} ou {ok: false, error: msg}. * Retorna {ok: true} ou {ok: false, error: msg}.
*/ */
async function updateStatus(sessionId, novoStatus) { async function updateStatus(sessionOrId, novoStatus) {
if (!sessionId || busy.value) return { ok: false, error: 'busy' }; if (!sessionOrId || busy.value) return { ok: false, error: 'busy' };
busy.value = true; busy.value = true;
try { try {
// Caminho A — string UUID legado ou row real (id é UUID real).
const isObject = typeof sessionOrId === 'object' && sessionOrId !== null;
const isVirtual = isObject && !!sessionOrId.is_occurrence;
if (!isVirtual) {
const realId = isObject ? sessionOrId.id : sessionOrId;
if (!realId || typeof realId !== 'string' || realId.startsWith('rec::')) {
return { ok: false, error: 'ID inválido pra atualizar status (virtual sem row).' };
}
const { error: err } = await supabase const { error: err } = await supabase
.from('agenda_eventos') .from('agenda_eventos')
.update({ status: novoStatus }) .update({ status: novoStatus })
.eq('id', sessionId); .eq('id', realId);
if (err) throw err; if (err) throw err;
if (_lastPatientId) await load(_lastPatientId);
return { ok: true };
}
// Caminho B — ocorrência virtual: materializar antes de atualizar.
const row = sessionOrId;
const rid = row.recurrence_id;
const rDate = row.recurrence_date || row.original_date || String(row.inicio_em || '').slice(0, 10);
if (!rid || !rDate) {
return { ok: false, error: 'Ocorrência sem recurrence_id/date — não dá pra materializar.' };
}
// Já existe row materializada (mesmo recurrence_id+date)? Usa ela.
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) {
const { error: upErr } = await supabase
.from('agenda_eventos')
.update({ status: novoStatus })
.eq('id', existing.id);
if (upErr) throw upErr;
} else {
// Materializa NOVA row a partir da virtual. Owner/tenant via auth+store.
const { data: userData } = await supabase.auth.getUser();
const ownerId = userData?.user?.id || null;
let tenantId = null;
try {
const { useTenantStore } = await import('@/stores/tenantStore');
tenantId = useTenantStore().activeTenantId || null;
} catch { /* sem store — segue */ }
const newRow = {
owner_id: ownerId,
tenant_id: tenantId,
recurrence_id: rid,
recurrence_date: rDate,
patient_id: row.patient_id || row.paciente_id || _lastPatientId,
tipo: row.tipo || 'sessao',
status: novoStatus,
inicio_em: row.inicio_em,
fim_em: row.fim_em,
modalidade: row.modalidade || 'presencial',
titulo: row.titulo || null,
titulo_custom: row.titulo_custom || null,
observacoes: row.observacoes || null,
determined_commitment_id: row.determined_commitment_id || null,
price: row.price ?? null
};
const { error: insErr } = await supabase
.from('agenda_eventos')
.insert([newRow]);
if (insErr) throw insErr;
}
if (_lastPatientId) await load(_lastPatientId); if (_lastPatientId) await load(_lastPatientId);
return { ok: true }; return { ok: true };
} catch (e) { } catch (e) {
+26
View File
@@ -652,16 +652,42 @@ function fecharEvento() {
// ── Actions do MelissaEventoPanel ────────────────────────────── // ── Actions do MelissaEventoPanel ──────────────────────────────
// updateStatus: muda status no DB e refetcha agenda. Pattern espelha // updateStatus: muda status no DB e refetcha agenda. Pattern espelha
// AgendaTerapeutaPage (sem optimistic update por simplicidade no MVP). // 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) { async function updateEventoStatus(novoStatus, msgSucesso) {
const ev = eventoSelecionado.value; const ev = eventoSelecionado.value;
if (!ev?.id || eventoBusy.value) return; if (!ev?.id || eventoBusy.value) return;
eventoBusy.value = true; eventoBusy.value = true;
try { try {
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 const { error } = await supabase
.from('agenda_eventos') .from('agenda_eventos')
.update({ status: novoStatus }) .update({ status: novoStatus })
.eq('id', ev.id); .eq('id', ev.id);
if (error) throw error; if (error) throw error;
}
toast.add({ severity: 'success', summary: msgSucesso, life: 2200 }); toast.add({ severity: 'success', summary: msgSucesso, life: 2200 });
// Refetch: // Refetch:
// - M.refetch() alimenta a Agenda (FullCalendar + ocorrências virtuais) // - M.refetch() alimenta a Agenda (FullCalendar + ocorrências virtuais)
+20 -2
View File
@@ -321,8 +321,11 @@ async function revertRecordPaid(record) {
} }
// Handler de mutacao de status (Realizada / Falta / Cancelar) // 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) { async function updateSessionStatus(ev, novoStatus, msg) {
const result = await sessionsHook.updateStatus(ev.id, novoStatus); const result = await sessionsHook.updateStatus(ev, novoStatus);
if (result.ok) { if (result.ok) {
toast.add({ severity: 'success', summary: msg, life: 2200 }); toast.add({ severity: 'success', summary: msg, life: 2200 });
} else { } 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) { async function onSessaoDialogDelete(payload) {
if (typeof melissaAgenda?.onDialogDelete === 'function') { if (typeof melissaAgenda?.onDialogDelete === 'function') {
await melissaAgenda.onDialogDelete(payload); await melissaAgenda.onDialogDelete(payload);
@@ -2296,7 +2314,7 @@ onBeforeUnmount(() => {
:lock-patient="true" :lock-patient="true"
@save="onSessaoDialogSave" @save="onSessaoDialogSave"
@delete="onSessaoDialogDelete" @delete="onSessaoDialogDelete"
@updateSeriesEvent="onSessaoDialogSave" @updateSeriesEvent="onSessaoDialogUpdateSeries"
@editSeriesOccurrence="onSessaoDialogSave" @editSeriesOccurrence="onSessaoDialogSave"
> >
<template #headerLeft> <template #headerLeft>
@@ -806,7 +806,11 @@ function _buildHandlers(deps) {
} }
// ── onUpdateSeriesEvent — mudança de status numa ocorrência ── // ── 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 { try {
if (id) { if (id) {
await update(id, { status }); await update(id, { status });
@@ -814,20 +818,29 @@ function _buildHandlers(deps) {
} }
if (!is_virtual || !inicio_em) return; 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 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') .from('agenda_eventos')
.select('id') .select('id')
.eq('recurrence_id', rid) .eq('recurrence_id', rid)
.eq('recurrence_date', rDate) .eq('recurrence_date', rDate)
.maybeSingle(); .maybeSingle();
if (exErr) throw exErr;
if (existing?.id) { if (existing?.id) {
await update(existing.id, { status }); await update(existing.id, { status });
} else { } else {
const row = dialogEventRow.value || {};
await create({ await create({
owner_id: ownerId.value, owner_id: ownerId.value,
tenant_id: clinicTenantId.value, tenant_id: clinicTenantId.value,
@@ -841,6 +854,7 @@ function _buildHandlers(deps) {
titulo: row.titulo || 'Sessão', titulo: row.titulo || 'Sessão',
patient_id: row.patient_id || row.paciente_id || null, patient_id: row.patient_id || row.paciente_id || null,
determined_commitment_id: row.determined_commitment_id || null, determined_commitment_id: row.determined_commitment_id || null,
modalidade: row.modalidade || 'presencial',
price: row.price ?? null price: row.price ?? null
}); });
} }
@@ -20,13 +20,16 @@
* *
* Sem auth/tenant: retorna [] silencioso (UI segue funcional). * Sem auth/tenant: retorna [] silencioso (UI segue funcional).
* *
* NÃO inclui ocorrências virtuais de recorrência (loadAndExpand) pra simplificar * Inclui ocorrências virtuais de recorrência (via useRecurrence.loadAndExpand)
* o preview. Adicionar quando promover Melissa pra produção. * 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 { ref, watch, onMounted, computed } from 'vue';
import { supabase } from '@/lib/supabase/client'; import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore'; import { useTenantStore } from '@/stores/tenantStore';
import { useMelissaCacheStore, MELISSA_CACHE_TTL } from '@/stores/melissaCacheStore'; 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) ── // ── Cores por tipo/status (consistente com o resto do Melissa) ──
function pickColor(tipo, status) { function pickColor(tipo, status) {
@@ -48,29 +51,43 @@ function isoToDecimalHour(iso) {
} }
function normalizeEvent(r) { 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 { return {
id: r.id, id: r.id,
tipo: r.tipo || 'sessao', tipo: r.tipo || 'sessao',
status: r.status || '', status: r.status || '',
titulo: r.titulo || '', titulo: r.titulo || r.titulo_custom || '',
patient_id: r.patient_id || null, patient_id: r.patient_id || null,
pacienteNome: pacNome, 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 || '', modalidade: r.modalidade || '',
descricao: r.observacoes || '', descricao: r.observacoes || '',
color: pickColor(r.tipo, r.status), color: pickColor(r.tipo, r.status),
label: pacNome || r.titulo || '—', label: pacNome || r.titulo || r.titulo_custom || '—',
inicio_em: r.inicio_em, inicio_em: r.inicio_em,
fim_em: r.fim_em, fim_em: r.fim_em,
startH: isoToDecimalHour(r.inicio_em), startH: isoToDecimalHour(r.inicio_em),
endH: isoToDecimalHour(r.fim_em), endH: isoToDecimalHour(r.fim_em),
dateKey: String(r.inicio_em || '').slice(0, 10), dateKey: String(r.inicio_em || '').slice(0, 10),
price: r.price != null ? Number(r.price) : 0, 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 ── // ── 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) { async function _fetchRange(start, end) {
const tenantStore = useTenantStore(); const tenantStore = useTenantStore();
const { data: userData } = await supabase.auth.getUser(); const { data: userData } = await supabase.auth.getUser();
@@ -82,9 +99,11 @@ async function _fetchRange(start, end) {
if (!userId || !tid) return []; 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 const { data, error } = await supabase
.from('agenda_eventos') .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('owner_id', userId)
.is('mirror_of_event_id', null) .is('mirror_of_event_id', null)
.gte('inicio_em', start.toISOString()) .gte('inicio_em', start.toISOString())
@@ -92,7 +111,20 @@ async function _fetchRange(start, end) {
.order('inicio_em', { ascending: true }); .order('inicio_em', { ascending: true });
if (error) throw error; 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 ────────────────────────────────────────────── // ── Range helpers ──────────────────────────────────────────────
@@ -293,14 +325,33 @@ export function useMelissaTodasSessoesPaciente() {
const { data, error: err } = await supabase const { data, error: err } = await supabase
.from('agenda_eventos') .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('owner_id', userId)
.eq('patient_id', patientId) .eq('patient_id', patientId)
.is('mirror_of_event_id', null) .is('mirror_of_event_id', null)
.order('inicio_em', { ascending: false }); .order('inicio_em', { ascending: false });
if (err) throw err; 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) { } catch (e) {
error.value = e?.message || 'Erro ao carregar sessões'; error.value = e?.message || 'Erro ao carregar sessões';
eventos.value = []; eventos.value = [];