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:
@@ -87,10 +87,46 @@ export function useAgendaEventActions({
|
||||
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', composer.form.value.id)
|
||||
.eq('id', formId)
|
||||
.select()
|
||||
.single();
|
||||
if (error) throw error;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
import { ref, computed } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useRecurrence } from '@/features/agenda/composables/useRecurrence';
|
||||
|
||||
export function usePatientSessions() {
|
||||
const sessions = ref([]);
|
||||
@@ -19,6 +20,9 @@ export function usePatientSessions() {
|
||||
const busy = ref(false); // mutations em curso (updateStatus etc)
|
||||
let _lastPatientId = null;
|
||||
|
||||
// Instancia local — refs internos (rules/exceptions) ficam isolados deste consumidor.
|
||||
const { loadAndExpand } = useRecurrence();
|
||||
|
||||
async function load(patientId) {
|
||||
_lastPatientId = patientId || null;
|
||||
if (!patientId) {
|
||||
@@ -29,14 +33,49 @@ export function usePatientSessions() {
|
||||
error.value = '';
|
||||
sessions.value = [];
|
||||
try {
|
||||
// 1. Linhas reais — `recurrence_id`/`recurrence_date` inclusos pra
|
||||
// mergeWithStoredSessions deduplicar virtuais de sessões já materializadas.
|
||||
const { data, error: err } = await supabase
|
||||
.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)
|
||||
.order('inicio_em', { ascending: false })
|
||||
.limit(100);
|
||||
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) {
|
||||
error.value = e?.message || 'Falha ao carregar sessões.';
|
||||
sessions.value = [];
|
||||
@@ -145,17 +184,96 @@ export function usePatientSessions() {
|
||||
/**
|
||||
* Atualiza o status de uma sessao (mutation). Recarrega a lista do paciente
|
||||
* 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}.
|
||||
*/
|
||||
async function updateStatus(sessionId, novoStatus) {
|
||||
if (!sessionId || busy.value) return { ok: false, error: 'busy' };
|
||||
async function updateStatus(sessionOrId, novoStatus) {
|
||||
if (!sessionOrId || busy.value) return { ok: false, error: 'busy' };
|
||||
busy.value = true;
|
||||
try {
|
||||
const { error: err } = await supabase
|
||||
// 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
|
||||
.from('agenda_eventos')
|
||||
.update({ status: novoStatus })
|
||||
.eq('id', realId);
|
||||
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')
|
||||
.update({ status: novoStatus })
|
||||
.eq('id', sessionId);
|
||||
if (err) throw err;
|
||||
.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);
|
||||
return { ok: true };
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user