diff --git a/src/features/agenda/composables/useAgendaEventActions.js b/src/features/agenda/composables/useAgendaEventActions.js index 38f32fc..8c7ddcb 100644 --- a/src/features/agenda/composables/useAgendaEventActions.js +++ b/src/features/agenda/composables/useAgendaEventActions.js @@ -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; diff --git a/src/features/patients/composables/usePatientSessions.js b/src/features/patients/composables/usePatientSessions.js index e6271fa..19395cd 100644 --- a/src/features/patients/composables/usePatientSessions.js +++ b/src/features/patients/composables/usePatientSessions.js @@ -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) { diff --git a/src/layout/melissa/MelissaLayout.vue b/src/layout/melissa/MelissaLayout.vue index 17b1e43..01a9fb8 100644 --- a/src/layout/melissa/MelissaLayout.vue +++ b/src/layout/melissa/MelissaLayout.vue @@ -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) diff --git a/src/layout/melissa/MelissaPaciente.vue b/src/layout/melissa/MelissaPaciente.vue index 692be89..5aad620 100644 --- a/src/layout/melissa/MelissaPaciente.vue +++ b/src/layout/melissa/MelissaPaciente.vue @@ -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" >