/* * useMelissaAgenda — Orquestrador da Agenda dentro do Layout Melissa. * -------------------------------------------------- * Equivalente Melissa-exclusivo de AgendaTerapeutaPage.vue: carrega settings, * eventos reais (useAgendaEvents), expande recorrências (useRecurrence), * resolve compromissos determinados (catálogo) e feriados, mantém o estado * do dialog de edição, e expõe TODOS os handlers que o dialog/FC precisam: * onDialogSave / onDialogDelete — CRUD com 7 cases (avulso, recorrente, * somente_este, este_e_seguintes, todos, * todos_sem_excecao) * onUpdateSeriesEvent — mudança de status numa ocorrência * onEditSeriesOccurrence — preview ao alternar editScope * persistMoveOrResize — drag/resize do FC com checagem de conflito * onSelectTime — click-drag pra criar evento novo * onEditEvento — popula dialogEventRow a partir do raw row * * Eventos retornados em `eventos` são Melissa-shape (color, label, startH/endH, * dateKey, _raw) — `_raw` carrega o registro bruto + flags de ocorrência virtual * pra alimentar o AgendaEventDialog. * * Cria refs `viewStart`/`viewEnd` internamente — MelissaAgenda escreve neles * no datesSet do FC (via provide/inject). O composable observa e refetcha. * * Os handlers exibem toasts (success/warn) — o composable assume que os * componentes consumidores já registraram `` e ``. */ import { ref, computed, watch, onMounted } from 'vue'; import { useToast } from 'primevue/usetoast'; import { useConfirm } from 'primevue/useconfirm'; import { supabase } from '@/lib/supabase/client'; import { useTenantStore } from '@/stores/tenantStore'; import { useAgendaSettings } from '@/features/agenda/composables/useAgendaSettings'; import { useAgendaEvents } from '@/features/agenda/composables/useAgendaEvents'; import { useRecurrence } from '@/features/agenda/composables/useRecurrence'; import { useDeterminedCommitments } from '@/features/agenda/composables/useDeterminedCommitments'; import { useCommitmentServices } from '@/features/agenda/composables/useCommitmentServices'; import { useFeriados } from '@/composables/useFeriados'; // ─── Constantes do domínio (espelhadas de AgendaTerapeutaPage) ────────────── const EVENTO_TIPO = Object.freeze({ SESSAO: 'sessao', BLOQUEIO: 'bloqueio' }); const EVENTO_TIPOS_VALIDOS = new Set([EVENTO_TIPO.SESSAO, EVENTO_TIPO.BLOQUEIO]); // Limite máximo de duração de sessão (regra de negócio Melissa). DB constraint // `session_duration_min_chk` permite 10–240; convencionamos 120 (2h) aqui pra // evitar slots gigantes acidentais. Futuro: ler de `agenda_configuracoes` se // `max_session_duration_min` for adicionado. const MAX_SESSION_MINUTES = 120; function isUuid(v) { return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(v || '')); } function normalizeEventoTipo(t, fallback = EVENTO_TIPO.SESSAO) { const s = String(t || '').trim().toLowerCase(); if (!s) return fallback; if (s.includes('sess')) return EVENTO_TIPO.SESSAO; if (s.includes('bloq') || s.includes('ocup')) return EVENTO_TIPO.BLOQUEIO; return EVENTO_TIPOS_VALIDOS.has(s) ? s : fallback; } function deriveEventoTipoForNewEvent(payload) { const vis = String(payload?.visibility_scope || '').toLowerCase(); const title = String(payload?.titulo || '').toLowerCase(); if (vis === 'busy_only' || title.includes('ocup')) return EVENTO_TIPO.BLOQUEIO; return EVENTO_TIPO.SESSAO; } function deriveTituloDefaultByTipo(tipo) { return normalizeEventoTipo(tipo, EVENTO_TIPO.SESSAO) === EVENTO_TIPO.BLOQUEIO ? 'Ocupado' : 'Sessão'; } function pickDbFields(obj) { const allowed = [ 'tenant_id', 'owner_id', 'terapeuta_id', 'patient_id', 'tipo', 'status', 'titulo', 'observacoes', 'inicio_em', 'fim_em', 'visibility_scope', 'mirror_of_event_id', 'mirror_source', 'determined_commitment_id', 'titulo_custom', 'extra_fields', 'recurrence_id', 'recurrence_date', 'price', 'insurance_plan_id', 'insurance_guide_number', 'insurance_value', 'insurance_plan_service_id' ]; const out = {}; for (const k of allowed) { if (obj[k] !== undefined) out[k] = obj[k]; } return out; } function _addMinutesToTime(timeStr, minutes) { const [h, m] = String(timeStr || '09:00').split(':').map(Number); const total = h * 60 + m + Number(minutes || 0); const hh = Math.floor(total / 60); const mm = total % 60; return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}:00`; } // ─── Melissa-style normalize (color, label, startH/endH, dateKey, _raw) ───── function pickColor(tipo, status, isOccurrence) { const s = String(status || '').toLowerCase(); if (s === 'realizado' || s === 'realizada') return '#10b981'; if (s === 'faltou') return '#ef4444'; if (s === 'cancelado' || s === 'cancelada') return '#94a3b8'; const t = String(tipo || '').toLowerCase(); if (t === 'bloqueio') return '#64748b'; if (t === 'supervisao' || t === 'supervisão') return '#a855f7'; if (t === 'reuniao' || t === 'reunião') return '#0ea5e9'; return isOccurrence ? '#8b5cf6' : '#6366f1'; // virtual: violet, real: indigo } function isoToDecimalHour(iso) { if (!iso) return 0; const d = new Date(iso); return d.getHours() + d.getMinutes() / 60; } function normalizeForMelissa(r) { // r pode vir de useAgendaEvents (linha real do banco) ou de loadAndExpand // (ocorrência virtual com is_occurrence=true e id "rec::uuid::date"). const isOccurrence = !!r.is_occurrence; const pacNome = r.paciente_nome ?? r.patient_name ?? r.patients?.nome_completo ?? ''; const tipo = normalizeEventoTipo(r.tipo, EVENTO_TIPO.SESSAO); return { id: r.id, tipo, status: r.status || (isOccurrence ? 'agendado' : ''), titulo: r.titulo || r.titulo_custom || '', patient_id: r.patient_id ?? r.paciente_id ?? null, pacienteNome: pacNome, modalidade: r.modalidade || '', descricao: r.observacoes || '', color: pickColor(tipo, r.status, isOccurrence), label: pacNome || r.titulo || r.titulo_custom || (tipo === EVENTO_TIPO.BLOQUEIO ? 'Ocupado' : '—'), 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), is_occurrence: isOccurrence, recurrence_id: r.recurrence_id ?? null, recurrence_date: r.recurrence_date ?? r.original_date ?? null, _raw: r // ← consumido pelo MelissaLayout pra popular dialogEventRow }; } // ─────────────────────────────────────────────────────────────────────────── // Composable principal // ─────────────────────────────────────────────────────────────────────────── // Símbolo de injeção pra MelissaAgenda recuperar o composable do MelissaLayout // sem prop drilling. Pattern: MelissaLayout chama `provide(MELISSA_AGENDA_KEY, m)` // e MelissaAgenda chama `inject(MELISSA_AGENDA_KEY)`. export const MELISSA_AGENDA_KEY = Symbol('melissaAgenda'); export function useMelissaAgenda() { const toast = useToast(); const confirm = useConfirm(); const tenantStore = useTenantStore(); // Refs do range visível — mutados pela MelissaAgenda no datesSet do FC. // Início default: semana ao redor de hoje (FC sobrescreve no mount). const viewStart = ref(new Date()); const viewEnd = ref(new Date()); { const hoje = new Date(); const dow = hoje.getDay(); const diff = dow === 0 ? -6 : 1 - dow; const segunda = new Date(hoje); segunda.setDate(hoje.getDate() + diff); segunda.setHours(0, 0, 0, 0); const domingoNext = new Date(segunda); domingoNext.setDate(segunda.getDate() + 7); viewStart.value = segunda; viewEnd.value = domingoNext; } const clinicTenantId = computed( () => tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null ); // ── Settings + workRules ──────────────────────────────────── const { settings, workRules, load: loadSettings } = useAgendaSettings(); const ownerId = computed(() => settings.value?.owner_id || ''); // ── Eventos reais (CRUD) ──────────────────────────────────── const { rows, loading: eventsLoading, error: eventsError, loadMyRange, create, update, remove } = useAgendaEvents(); // ── Recorrência ───────────────────────────────────────────── const { loadAndExpand, createRule, updateRule, cancelRule, splitRuleAt, cancelRuleFrom, upsertException } = useRecurrence(); const _occurrenceRows = ref([]); // ── Compromissos determinados (catálogo) ──────────────────── const { rows: determinedCommitments, load: loadDeterminedCommitments } = useDeterminedCommitments(clinicTenantId); // Adapta pro shape que o AgendaEventDialog espera (mesma lógica da // AgendaTerapeutaPage — prioridade pra "Sessão" primeiro) const commitmentOptions = computed(() => { const list = Array.isArray(determinedCommitments.value) ? determinedCommitments.value : []; const priority = new Map([ ['session', 0], ['class', 1], ['study', 2], ['reading', 3], ['supervision', 4], ['content_creation', 5] ]); return [...list] .filter((i) => i?.id && i?.active !== false) .sort((a, b) => { const pa = priority.has(a.native_key) ? priority.get(a.native_key) : 99; const pb = priority.has(b.native_key) ? priority.get(b.native_key) : 99; if (pa !== pb) return pa - pb; return String(a.name || '').localeCompare(String(b.name || ''), 'pt-BR'); }) .map((i) => ({ id: i.id, tenant_id: i.tenant_id ?? null, created_by: i.created_by ?? null, name: String(i.name || '').trim() || 'Sem nome', description: i.description || '', native_key: i.native_key || null, is_native: !!i.is_native, is_locked: !!i.is_locked, active: i.active !== false, bg_color: i.bg_color || null, text_color: i.text_color || null, fields: Array.isArray(i.determined_commitment_fields) ? [...i.determined_commitment_fields].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0)) : [] })); }); // ── Feriados + commitment services ────────────────────────── const { todos: feriados, fcEvents: feriadoFcEvents, load: loadFeriadosBase } = useFeriados(); const { saveRuleItems, propagateToSerie } = useCommitmentServices(); // ── Linhas combinadas (real + virtual) ────────────────────── const allRows = computed(() => [...(rows.value || []), ...(_occurrenceRows.value || [])]); // ── Eventos Melissa-normalizados (consumidos pelo MelissaAgenda FC) ── const eventos = computed(() => allRows.value.map(normalizeForMelissa)); // ── Eventos do FC original (se precisar — AgendaEventDialog quer // `allEvents` no shape FC pra checar conflitos) ───────── const allEventsForDialog = computed(() => allRows.value.map((r) => ({ id: r.id, start: r.inicio_em, end: r.fim_em, extendedProps: { ...r } })) ); // ── Dialog state ──────────────────────────────────────────── const dialogOpen = ref(false); const dialogEventRow = ref(null); const dialogStartISO = ref(''); const dialogEndISO = ref(''); // Bloqueio dialog (modo: 'horario' | 'periodo' | 'dia' | 'feriados') const bloqueioDialogOpen = ref(false); const bloqueioMode = ref('horario'); function openBloqueioDialog(mode) { if (!ownerId.value) { toast.add({ severity: 'warn', summary: 'Agenda', detail: 'Aguarde carregar as configurações da agenda.', life: 3000 }); return; } bloqueioMode.value = mode || 'horario'; bloqueioDialogOpen.value = true; } // ── Refetch (com merge real+virtual) ──────────────────────── async function _reloadRange() { const s = viewStart.value; const e = viewEnd.value; if (!s || !e) return; // Aguarda ownerId — settings é async if (!ownerId.value) { const unwatch = watch(ownerId, async (v) => { if (!v) return; unwatch(); await _reloadRange(); }); return; } const start = new Date(s); const end = new Date(e); const tid = clinicTenantId.value; await loadMyRange(start.toISOString(), end.toISOString(), ownerId.value); // Expande regras + merge com sessões reais const merged = await loadAndExpand(ownerId.value, start, end, rows.value, tid); _occurrenceRows.value = merged.filter((r) => r.is_occurrence); } async function refetch() { await _reloadRange(); } // ── Inicialização ─────────────────────────────────────────── onMounted(async () => { await loadSettings(); await loadDeterminedCommitments(); const tid = clinicTenantId.value; if (tid) await loadFeriadosBase(tid); }); // Reload quando view muda OU quando settings/ownerId aparece watch([viewStart, viewEnd], _reloadRange); watch(ownerId, (v) => { if (v) _reloadRange(); }); // ────────────────────────────────────────────────────────── // Handlers — populados na Stage 2 // ────────────────────────────────────────────────────────── const _stageTwo = {}; // placeholder pra próxima passada Object.assign(_stageTwo, _buildHandlers({ toast, confirm, supabase, ownerId, clinicTenantId, settings, rows, allRows, eventsError, create, update, remove, loadAndExpand, createRule, updateRule, cancelRule, splitRuleAt, cancelRuleFrom, upsertException, saveRuleItems, propagateToSerie, dialogOpen, dialogEventRow, dialogStartISO, dialogEndISO, _reloadRange })); return { // View range (mutado pela MelissaAgenda via FC.datesSet) viewStart, viewEnd, // Eventos pro FC eventos, rawRows: allRows, loading: eventsLoading, refetch, // Dialog state dialogOpen, dialogEventRow, dialogStartISO, dialogEndISO, // Bloqueio dialog bloqueioDialogOpen, bloqueioMode, openBloqueioDialog, // Settings + commitments + feriados (props pro AgendaEventDialog) settings, workRules, ownerId, clinicTenantId, commitmentOptions, feriados, feriadoFcEvents, allEventsForDialog, // Handlers ..._stageTwo }; } // ─────────────────────────────────────────────────────────────────────────── // Handlers — extraídos numa função à parte só por organização (mantém o // composable principal legível). Stage 2 preenche as funções abaixo. // ─────────────────────────────────────────────────────────────────────────── function _buildHandlers(deps) { // Só desempacota o que os handlers desta função usam diretamente — // _buildOnDialogSave/_buildOnDialogDelete recebem `deps` completo. const { toast, confirm, ownerId, clinicTenantId, settings, allRows, eventsError, create, update, dialogOpen, dialogEventRow, dialogStartISO, dialogEndISO } = deps; // Helpers de formatação pra mensagens de confirm/toast (PT-BR estilo // "16h" / "15:15h" / "27/04"). function _fmtH(d) { if (!d) return '?'; const h = d.getHours(); const m = d.getMinutes(); return m === 0 ? `${h}h` : `${h}:${String(m).padStart(2, '0')}h`; } function _fmtD(d) { if (!d) return ''; return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}`; } // Escape HTML — usado quando a mensagem do confirm tem partes // controladas pelo usuário (nome do paciente) e v-html no slot. function _esc(s) { return String(s ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } // ── onEditEvento — chamado pelo MelissaEventoPanel ao clicar "Editar" ── // Recebe o `_raw` normalizado e popula o dialogEventRow no shape esperado // pelo AgendaEventDialog (mesma estrutura de AgendaTerapeutaPage.onEventClick). function onEditEvento(rawRow) { if (!rawRow) return; dialogEventRow.value = { id: rawRow.is_occurrence ? null : rawRow.id || null, owner_id: rawRow.owner_id ?? ownerId.value, terapeuta_id: rawRow.terapeuta_id ?? null, paciente_id: rawRow.patient_id ?? rawRow.paciente_id ?? null, paciente_nome: rawRow.paciente_nome ?? rawRow.patient_name ?? rawRow.patients?.nome_completo ?? null, paciente_avatar: rawRow.paciente_avatar ?? rawRow.patients?.avatar_url ?? null, paciente_status: rawRow.paciente_status ?? rawRow.patients?.status ?? null, tipo: rawRow.tipo || 'sessao', status: rawRow.status, titulo: rawRow.titulo, observacoes: rawRow.observacoes ?? null, visibility_scope: rawRow.visibility_scope ?? 'public', inicio_em: rawRow.inicio_em, fim_em: rawRow.fim_em, modalidade: rawRow.modalidade ?? null, determined_commitment_id: rawRow.determined_commitment_id ?? null, titulo_custom: rawRow.titulo_custom ?? null, extra_fields: rawRow.extra_fields ?? null, price: rawRow.price != null ? Number(rawRow.price) : null, insurance_plan_id: rawRow.insurance_plan_id ?? null, insurance_guide_number: rawRow.insurance_guide_number ?? null, insurance_value: rawRow.insurance_value != null ? Number(rawRow.insurance_value) : null, insurance_plan_service_id: rawRow.insurance_plan_service_id ?? null, // recorrência recurrence_id: rawRow.recurrence_id ?? null, original_date: rawRow.original_date ?? rawRow.recurrence_date ?? null, is_occurrence: !!rawRow.is_occurrence, exception_type: rawRow.exception_type ?? rawRow.exceptionType ?? null, // legado serie_id: rawRow.serie_id ?? rawRow.recurrence_id ?? null, serie_dia_semana: rawRow.serie_dia_semana ?? null, serie_hora: rawRow.serie_hora ?? null }; dialogStartISO.value = ''; dialogEndISO.value = ''; dialogOpen.value = true; } // ── onCreateEvento — botão "+ Agendar" sem seleção no FC ── // Espelha AgendaTerapeutaPage.onCreateFromButton:1423 — usa o dia // visualizado + hora atual como defaults razoáveis. function onCreateEvento() { if (!ownerId.value) { toast.add({ severity: 'warn', summary: 'Agenda', detail: 'Aguarde carregar as configurações da agenda.', life: 3000 }); return; } const durMin = settings.value?.session_duration_min ?? settings.value?.duracao_padrao_minutos ?? 50; const base = new Date(); // Arredonda pra próximo slot de 15min — UX mais limpa que minuto solto. base.setSeconds(0, 0); const remainder = base.getMinutes() % 15; if (remainder !== 0) { base.setMinutes(base.getMinutes() + (15 - remainder)); } dialogEventRow.value = { owner_id: ownerId.value, terapeuta_id: null, paciente_id: null, tipo: EVENTO_TIPO.SESSAO, status: 'agendado', titulo: deriveTituloDefaultByTipo(EVENTO_TIPO.SESSAO), observacoes: null, visibility_scope: 'public', determined_commitment_id: null }; dialogStartISO.value = base.toISOString(); dialogEndISO.value = new Date(base.getTime() + durMin * 60000).toISOString(); dialogOpen.value = true; } // ── onSelectTime — click-drag no FC pra criar evento novo ── // Dinâmica de duração: // click sem drag → settings.session_duration_min (default 50) // drag ≤ default → default (mínimo da configuração) // drag entre default e MAX_SESSION_MINUTES → respeita o drag (snap em // 15min já feito pelo FC via snapDuration) // drag > MAX → capa em MAX (regra de negócio Melissa: 2h // é o slot máximo permitido) function onSelectTime(selection) { const defaultDur = settings.value?.session_duration_min ?? settings.value?.duracao_padrao_minutos ?? 50; const rawStart = selection.start instanceof Date ? selection.start : new Date(selection.start); const rawEnd = selection.end ? (selection.end instanceof Date ? selection.end : new Date(selection.end)) : null; const dragMin = rawEnd ? Math.round((rawEnd.getTime() - rawStart.getTime()) / 60000) : 0; const durMin = dragMin <= defaultDur ? defaultDur : dragMin <= MAX_SESSION_MINUTES ? dragMin : MAX_SESSION_MINUTES; const startISO = rawStart.toISOString(); const endISO = new Date(rawStart.getTime() + durMin * 60000).toISOString(); dialogEventRow.value = { owner_id: ownerId.value, terapeuta_id: null, paciente_id: null, tipo: EVENTO_TIPO.SESSAO, status: 'agendado', titulo: deriveTituloDefaultByTipo(EVENTO_TIPO.SESSAO), observacoes: null, visibility_scope: 'public', determined_commitment_id: null }; dialogStartISO.value = startISO; dialogEndISO.value = endISO; dialogOpen.value = true; } // ── persistMoveOrResize — drag/resize do FC ── // Fluxo: // 1. Bail se for ocorrência virtual (sem id real) // 2. Conflict-check ANTES de confirmar (revert+toast imediato evita // sequência ruim de "confirma? > ah não, conflito") // 3. Confirm dialog descrevendo a alteração ("trocando Leonardo de // 16h para 15:15h, confirma?") // 4. Se aceitar, UPDATE + toast success. Se recusar, revert. async function persistMoveOrResize(info, actionLabel) { try { const ev = info?.event; if (!ev) return; const id = ev.id; if (!id || !isUuid(id)) { info?.revert?.(); toast.add({ severity: 'info', summary: 'Sessão recorrente', detail: 'Para mover uma sessão da série, abra-a e edite com "Somente esta sessão".', life: 4500 }); return; } const startISO = ev.start ? ev.start.toISOString() : null; const endISO = ev.end ? ev.end.toISOString() : null; if (!startISO || !endISO) throw new Error('Compromisso sem start/end.'); const start = new Date(startISO); const end = new Date(endISO); const breakMin = settings.value?.session_break_min || 0; const conflict = allRows.value.find((r) => { if (!r.inicio_em || r.id === id) return false; const rS = new Date(r.inicio_em); const rE = new Date(r.fim_em || r.inicio_em); return start < new Date(rE.getTime() + breakMin * 60000) && end > rS; }); if (conflict) { info?.revert?.(); toast.add({ severity: 'warn', summary: 'Conflito', detail: 'Já existe um compromisso neste horário.', life: 4000 }); return; } // Confirm: monta mensagem descrevendo o que mudou. Usa HTML // (renderizado via v-html no slot #message do ConfirmDialog em // MelissaLayout) pra destacar datas/horas em . Subject // é escapado pra evitar XSS via nome de paciente. const oldStart = info.oldEvent?.start || null; const oldEnd = info.oldEvent?.end || null; const ext = ev.extendedProps || {}; const subject = ext.pacienteNome || ext.label || ext.titulo || 'evento'; const subjectEsc = _esc(subject); const startMoved = oldStart && start.getTime() !== oldStart.getTime(); const endMoved = oldEnd && end.getTime() !== oldEnd.getTime(); let message; if (startMoved) { const sameDay = oldStart.toDateString() === start.toDateString(); if (sameDay) { // Mudou só hora (mesmo dia) message = `Está trocando o horário de ${subjectEsc} de ` + `${_fmtH(oldStart)} para ` + `${_fmtH(start)}. Confirma?`; } else { // Mudou de dia (com ou sem mudança de hora) — destaca // data E hora em ambos os lados message = `Está movendo ${subjectEsc} de ` + `${_fmtD(oldStart)} ${_fmtH(oldStart)} para ` + `${_fmtD(start)} ${_fmtH(start)}. Confirma?`; } } else if (endMoved) { // Só resize — mostra duração antiga → nova const oldDur = Math.round((oldEnd.getTime() - oldStart.getTime()) / 60000); const newDur = Math.round((end.getTime() - start.getTime()) / 60000); message = `Está alterando a duração de ${subjectEsc} de ` + `${oldDur}min para ` + `${newDur}min. Confirma?`; } else { message = `Confirmar alteração de ${subjectEsc}?`; } const accepted = await new Promise((resolve) => { confirm.require({ header: actionLabel, message, icon: 'pi pi-clock', acceptLabel: 'Confirmar', rejectLabel: 'Cancelar', acceptClass: 'p-button-primary', rejectClass: 'p-button-text', accept: () => resolve(true), reject: () => resolve(false), onHide: () => resolve(false) // fechar via Esc/clickoutside conta como cancelar }); }); if (!accepted) { info?.revert?.(); return; } await update(id, { inicio_em: startISO, fim_em: endISO }); toast.add({ severity: 'success', summary: actionLabel, detail: 'Alteração salva.', life: 1800 }); } catch (e) { info?.revert?.(); toast.add({ severity: 'warn', summary: 'Erro', detail: eventsError.value || e?.message || 'Falha ao salvar alteração.', life: 4500 }); } } // ── onEditSeriesOccurrence — preview ao alternar editScope no dialog ── function onEditSeriesOccurrence({ id, recurrence_date, inicio_em, fim_em, is_virtual }) { const current = dialogEventRow.value || {}; dialogEventRow.value = { ...current, id: id || null, inicio_em, fim_em, recurrence_date, _is_virtual: is_virtual }; } // ── onUpdateSeriesEvent — mudança de status numa ocorrência ── async function onUpdateSeriesEvent({ id, status, recurrence_date, inicio_em, fim_em, is_virtual }) { try { if (id) { await update(id, { status }); return; } if (!is_virtual || !inicio_em) return; const rid = dialogEventRow.value?.recurrence_id ?? dialogEventRow.value?.serie_id ?? null; const rDate = recurrence_date || inicio_em?.slice(0, 10); const { data: existing } = await supabase .from('agenda_eventos') .select('id') .eq('recurrence_id', rid) .eq('recurrence_date', rDate) .maybeSingle(); if (existing?.id) { await update(existing.id, { status }); } else { const row = dialogEventRow.value || {}; await create({ owner_id: ownerId.value, tenant_id: clinicTenantId.value, recurrence_id: rid, recurrence_date: rDate, tipo: 'sessao', status, inicio_em, fim_em, visibility_scope: 'public', titulo: row.titulo || 'Sessão', patient_id: row.patient_id || row.paciente_id || null, determined_commitment_id: row.determined_commitment_id || null, price: row.price ?? null }); } } catch (e) { toast.add({ severity: 'warn', summary: 'Erro', detail: e?.message || 'Falha ao atualizar status.', life: 3000 }); } } // ── onDialogSave / onDialogDelete ── populados na Stage 2 ── const onDialogSave = _buildOnDialogSave(deps); const onDialogDelete = _buildOnDialogDelete(deps); return { onEditEvento, onCreateEvento, onSelectTime, persistMoveOrResize, onEditSeriesOccurrence, onUpdateSeriesEvent, onDialogSave, onDialogDelete }; } // ─────────────────────────────────────────────────────────────────────────── // onDialogSave — porte direto de AgendaTerapeutaPage.onDialogSave (linhas // 1714-2116). Cobre 7 cases: criação avulsa, sessão única (A/B), série nova // (C), edição "somente esta" (D), "este e seguintes" (E), "todos" (F), // "todos sem exceção" (G), além de tratamento explícito de exclusion // constraint (conflict de horário). // ─────────────────────────────────────────────────────────────────────────── function _buildOnDialogSave(deps) { const { toast, confirm, ownerId, clinicTenantId, settings, eventsError, create, update, createRule, updateRule, splitRuleAt, upsertException, saveRuleItems, propagateToSerie, dialogOpen, dialogEventRow, _reloadRange } = deps; return async function onDialogSave(arg) { let normalized = null; try { const isWrapped = !!arg && typeof arg === 'object' && Object.prototype.hasOwnProperty.call(arg, 'payload'); const payload = isWrapped ? arg.payload : arg; const recorrencia = arg?.recorrencia ?? null; const editMode = arg?.editMode ?? null; const recurrenceId = arg?.recurrence_id ?? arg?.serie_id ?? null; const originalDate = arg?.original_date ?? dialogEventRow.value?.original_date ?? null; const id = isWrapped ? (arg.id ?? null) : (arg?.id ?? null); normalized = { ...(payload || {}) }; if (!normalized.owner_id && ownerId.value) normalized.owner_id = ownerId.value; const clinicId = clinicTenantId.value; if (!clinicId) throw new Error('tenant_id da clínica não encontrado no tenantStore.'); normalized.tenant_id = clinicId; if (!normalized.visibility_scope) normalized.visibility_scope = 'public'; normalized.tipo = normalizeEventoTipo(normalized.tipo || deriveEventoTipoForNewEvent(normalized), EVENTO_TIPO.SESSAO); if (!normalized.status) normalized.status = 'agendado'; if (!String(normalized.titulo || '').trim()) normalized.titulo = deriveTituloDefaultByTipo(normalized.tipo); if (!normalized.paciente_id || !isUuid(normalized.paciente_id)) normalized.paciente_id = null; if (normalized.tipo === EVENTO_TIPO.BLOQUEIO) { normalized.paciente_id = null; normalized.determined_commitment_id = null; if (!normalized.visibility_scope) normalized.visibility_scope = 'busy_only'; } if (normalized.determined_commitment_id && !isUuid(normalized.determined_commitment_id)) { normalized.determined_commitment_id = null; } // ── CASO C / C2: criação RECORRENTE ─────────────────────────────── if (recorrencia?.tipo === 'recorrente' && !recurrenceId) { const startDate = new Date(normalized.inicio_em); const tipoFreq = recorrencia.tipoFreq ?? 'semanal'; const dow = recorrencia.diaSemana ?? startDate.getDay(); const firstRecISO = startDate.toISOString().slice(0, 10); let ruleType = 'weekly'; let interval = 1; let weekdays = [dow]; if (tipoFreq === 'quinzenal') { ruleType = 'weekly'; interval = 2; } else if (tipoFreq === 'diasEspecificos') { ruleType = 'custom_weekdays'; weekdays = recorrencia.diasSemana?.length ? recorrencia.diasSemana : [dow]; } const rule = { tenant_id: clinicId, owner_id: normalized.owner_id, therapist_id: normalized.terapeuta_id ?? null, patient_id: normalized.paciente_id ?? null, determined_commitment_id: normalized.determined_commitment_id ?? null, type: ruleType, interval, weekdays, start_time: recorrencia.horaInicio ?? startDate.toTimeString().slice(0, 8), end_time: _addMinutesToTime(recorrencia.horaInicio ?? startDate.toTimeString().slice(0, 5), recorrencia.duracaoMin ?? 50), duration_min: recorrencia.duracaoMin ?? 50, timezone: settings.value?.timezone || 'America/Sao_Paulo', start_date: firstRecISO, end_date: recorrencia.dataFim ? new Date(recorrencia.dataFim).toISOString().slice(0, 10) : null, max_occurrences: recorrencia.qtdSessoes ?? null, open_ended: !recorrencia.dataFim && !recorrencia.qtdSessoes, modalidade: normalized.modalidade ?? 'presencial', titulo_custom: normalized.titulo_custom ?? null, observacoes: normalized.observacoes ?? null, extra_fields: normalized.extra_fields ?? null, price: normalized.price ?? null, insurance_plan_id: normalized.insurance_plan_id ?? null, insurance_guide_number: normalized.insurance_guide_number ?? null, insurance_value: normalized.insurance_value ?? null, insurance_plan_service_id: normalized.insurance_plan_service_id ?? null, status: 'ativo' }; const createdRule = await createRule(rule); if (id && createdRule?.id) { await update(id, { recurrence_id: createdRule.id, recurrence_date: firstRecISO }); } if (recorrencia?.conflitos?.length && createdRule?.id) { const exceptions = recorrencia.conflitos.map((c) => ({ recurrence_id: createdRule.id, tenant_id: clinicId, original_date: c.date, type: c.conflict.type === 'feriado' ? 'holiday_block' : c.conflict.type === 'bloqueado' ? 'cancel_session' : c.conflict.type === 'folga' ? 'cancel_session' : 'cancel_session', reason: c.conflict.label })); const { error: exErr } = await supabase.from('recurrence_exceptions').insert(exceptions); if (exErr) console.warn('[useMelissaAgenda] exceptions insert', exErr); } if (createdRule?.id && recorrencia.commitmentItems?.length) { await saveRuleItems(createdRule.id, recorrencia.commitmentItems); } const detail = recorrencia.qtdSessoes ? `${recorrencia.qtdSessoes} sessões criadas` : 'Série recorrente criada'; toast.add({ severity: 'success', summary: 'Série criada', detail, life: 3000 }); dialogOpen.value = false; await _reloadRange(); if (createdRule?.id && recorrencia.commitmentItems?.length && recorrencia.qtdSessoes && normalized.paciente_id) { await _offerBillingContract({ normalized, recorrencia, tenantId: clinicId, confirm, toast }); } return; } // ── CASO D: edição "somente_este" ───────────────────────────────── if (recurrenceId && editMode === 'somente_este') { let eventId = id ?? null; if (id) { await update(id, pickDbFields(normalized)); if (originalDate) { await upsertException({ recurrence_id: recurrenceId, tenant_id: clinicId, original_date: originalDate, type: 'reschedule_session', new_date: normalized.inicio_em?.slice(0, 10), new_start_time: normalized.inicio_em ? new Date(normalized.inicio_em).toTimeString().slice(0, 8) : null, new_end_time: normalized.fim_em ? new Date(normalized.fim_em).toTimeString().slice(0, 8) : null, modalidade: normalized.modalidade ?? null, titulo_custom: normalized.titulo_custom ?? null, observacoes: normalized.observacoes ?? null, extra_fields: normalized.extra_fields ?? null }); } } else if (originalDate) { await upsertException({ recurrence_id: recurrenceId, tenant_id: clinicId, original_date: originalDate, type: 'reschedule_session', new_date: normalized.inicio_em?.slice(0, 10), new_start_time: normalized.inicio_em ? new Date(normalized.inicio_em).toTimeString().slice(0, 8) : null, new_end_time: normalized.fim_em ? new Date(normalized.fim_em).toTimeString().slice(0, 8) : null, modalidade: normalized.modalidade ?? null, titulo_custom: normalized.titulo_custom ?? null, observacoes: normalized.observacoes ?? null, extra_fields: normalized.extra_fields ?? null }); if (arg.onSaved) { const { data: existing } = await supabase .from('agenda_eventos') .select('id') .eq('recurrence_id', recurrenceId) .eq('recurrence_date', originalDate) .maybeSingle(); if (existing?.id) { eventId = existing.id; } else { const mat = await create({ owner_id: normalized.owner_id, tenant_id: clinicId, recurrence_id: recurrenceId, recurrence_date: originalDate, tipo: normalized.tipo, status: normalized.status, inicio_em: normalized.inicio_em, fim_em: normalized.fim_em, titulo: normalized.titulo, patient_id: normalized.patient_id, determined_commitment_id: normalized.determined_commitment_id, modalidade: normalized.modalidade ?? 'presencial', observacoes: normalized.observacoes ?? null, extra_fields: normalized.extra_fields ?? null, price: normalized.price ?? null, insurance_plan_id: normalized.insurance_plan_id ?? null, insurance_guide_number: normalized.insurance_guide_number ?? null, insurance_value: normalized.insurance_value ?? null, insurance_plan_service_id: normalized.insurance_plan_service_id ?? null }); eventId = mat.id; } } } if (eventId) await arg.onSaved?.(eventId, { markCustomized: true }); toast.add({ severity: 'success', summary: 'Salvo', detail: 'Sessão atualizada individualmente.', life: 2500 }); dialogOpen.value = false; await _reloadRange(); return; } // ── CASO E: edição "este_e_seguintes" ───────────────────────────── if (recurrenceId && editMode === 'este_e_seguintes' && originalDate) { const newRuleId = await splitRuleAt(recurrenceId, originalDate); const startDate = new Date(normalized.inicio_em); await updateRule(newRuleId, { weekdays: [startDate.getDay()], start_time: startDate.toTimeString().slice(0, 8), end_time: normalized.fim_em ? new Date(normalized.fim_em).toTimeString().slice(0, 8) : undefined, duration_min: recorrencia?.duracaoMin ?? 50, modalidade: normalized.modalidade ?? 'presencial', titulo_custom: normalized.titulo_custom ?? null, observacoes: normalized.observacoes ?? null, extra_fields: normalized.extra_fields ?? null, price: normalized.price ?? null, insurance_plan_id: normalized.insurance_plan_id ?? null, insurance_guide_number: normalized.insurance_guide_number ?? null, insurance_value: normalized.insurance_value ?? null, insurance_plan_service_id: normalized.insurance_plan_service_id ?? null }); const serviceItemsE = arg.serviceItems; if (newRuleId && serviceItemsE?.length) { await saveRuleItems(newRuleId, serviceItemsE); await propagateToSerie(newRuleId, serviceItemsE, { fromDate: originalDate }); } if (id) await arg.onSaved?.(id); toast.add({ severity: 'success', summary: 'Salvo', detail: 'Esta sessão e as seguintes foram atualizadas.', life: 2500 }); dialogOpen.value = false; await _reloadRange(); return; } // ── CASO F: edição "todos" ──────────────────────────────────────── if (recurrenceId && editMode === 'todos') { const startDate = new Date(normalized.inicio_em); await updateRule(recurrenceId, { weekdays: [startDate.getDay()], start_time: startDate.toTimeString().slice(0, 8), end_time: normalized.fim_em ? new Date(normalized.fim_em).toTimeString().slice(0, 8) : undefined, duration_min: recorrencia?.duracaoMin ?? 50, modalidade: normalized.modalidade ?? 'presencial', titulo_custom: normalized.titulo_custom ?? null, observacoes: normalized.observacoes ?? null, extra_fields: normalized.extra_fields ?? null, price: normalized.price ?? null, insurance_plan_id: normalized.insurance_plan_id ?? null, insurance_guide_number: normalized.insurance_guide_number ?? null, insurance_value: normalized.insurance_value ?? null, insurance_plan_service_id: normalized.insurance_plan_service_id ?? null }); await supabase .from('agenda_eventos') .update({ modalidade: normalized.modalidade ?? 'presencial', titulo_custom: normalized.titulo_custom ?? null, observacoes: normalized.observacoes ?? null, extra_fields: normalized.extra_fields ?? null, price: normalized.price ?? null, insurance_plan_id: normalized.insurance_plan_id ?? null, insurance_guide_number: normalized.insurance_guide_number ?? null, insurance_value: normalized.insurance_value ?? null, insurance_plan_service_id: normalized.insurance_plan_service_id ?? null }) .eq('recurrence_id', recurrenceId); const serviceItemsF = arg.serviceItems; if (recurrenceId && serviceItemsF?.length) { await saveRuleItems(recurrenceId, serviceItemsF); await propagateToSerie(recurrenceId, serviceItemsF); } if (id) await arg.onSaved?.(id); toast.add({ severity: 'success', summary: 'Salvo', detail: 'Todas as sessões da série foram atualizadas.', life: 2500 }); dialogOpen.value = false; await _reloadRange(); return; } // ── CASO G: edição "todos_sem_excecao" ──────────────────────────── if (recurrenceId && editMode === 'todos_sem_excecao') { const startDate = new Date(normalized.inicio_em); await updateRule(recurrenceId, { weekdays: [startDate.getDay()], start_time: startDate.toTimeString().slice(0, 8), end_time: normalized.fim_em ? new Date(normalized.fim_em).toTimeString().slice(0, 8) : undefined, duration_min: recorrencia?.duracaoMin ?? 50, modalidade: normalized.modalidade ?? 'presencial', titulo_custom: normalized.titulo_custom ?? null, observacoes: normalized.observacoes ?? null, extra_fields: normalized.extra_fields ?? null, price: normalized.price ?? null, insurance_plan_id: normalized.insurance_plan_id ?? null, insurance_guide_number: normalized.insurance_guide_number ?? null, insurance_value: normalized.insurance_value ?? null, insurance_plan_service_id: normalized.insurance_plan_service_id ?? null }); await supabase .from('agenda_eventos') .update({ modalidade: normalized.modalidade ?? 'presencial', titulo_custom: normalized.titulo_custom ?? null, observacoes: normalized.observacoes ?? null, extra_fields: normalized.extra_fields ?? null, price: normalized.price ?? null, insurance_plan_id: normalized.insurance_plan_id ?? null, insurance_guide_number: normalized.insurance_guide_number ?? null, insurance_value: normalized.insurance_value ?? null, insurance_plan_service_id: normalized.insurance_plan_service_id ?? null, services_customized: false }) .eq('recurrence_id', recurrenceId); const serviceItemsG = arg.serviceItems; if (recurrenceId && serviceItemsG?.length) { await saveRuleItems(recurrenceId, serviceItemsG); await propagateToSerie(recurrenceId, serviceItemsG, { ignoreCustomized: true }); } if (id) await arg.onSaved?.(id); toast.add({ severity: 'success', summary: 'Salvo', detail: 'Todas as sessões da série foram atualizadas (sem exceção).', life: 2500 }); dialogOpen.value = false; await _reloadRange(); return; } // ── CASO A/B: evento avulso ou sessão única ────────────────────── const dbPayload = pickDbFields(normalized); if (id) { await update(id, dbPayload); await arg.onSaved?.(id); toast.add({ severity: 'success', summary: 'Salvo', detail: 'Compromisso atualizado.', life: 2500 }); } else { const created = await create(dbPayload); await arg.onSaved?.(created.id); toast.add({ severity: 'success', summary: 'Criado', detail: 'Compromisso criado.', life: 2500 }); } dialogOpen.value = false; await _reloadRange(); } catch (e) { const msg = String(e?.message || ''); if (msg.includes('recurrence_rules_dates_chk') || (msg.includes('violates check constraint') && msg.includes('recurrence_rules'))) { toast.add({ severity: 'warn', summary: 'Não foi possível dividir a série', detail: 'Esta é a primeira sessão da série. Para alterar todas as ocorrências, selecione "Todos" ou "Todos sem exceção".', life: 6000 }); return; } const isOverlap = e?.code === '23P01' || msg.includes('agenda_eventos_sem_sobreposicao') || msg.includes('exclusion constraint') || msg.includes('conflicting key value violates exclusion constraint'); if (isOverlap) { let detail = 'Já existe um compromisso nesse horário. Verifique a agenda e escolha outro horário.'; try { if (normalized?.inicio_em && normalized?.fim_em && normalized?.owner_id) { const { data: conflicting } = await supabase .from('agenda_eventos') .select('titulo, inicio_em, fim_em') .eq('owner_id', normalized.owner_id) .lt('inicio_em', normalized.fim_em) .gt('fim_em', normalized.inicio_em) .limit(1) .maybeSingle(); if (conflicting) { const ini = new Date(conflicting.inicio_em).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }); const fim = new Date(conflicting.fim_em).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }); const titulo = conflicting.titulo || 'Compromisso'; detail = `Conflito com "${titulo}" (${ini} → ${fim}). Ajuste o horário ou a duração.`; } } } catch { /* mantém detail genérico */ } toast.add({ severity: 'warn', summary: 'Conflito de horário', detail, life: 7000 }); return; } toast.add({ severity: 'warn', summary: 'Erro', detail: eventsError.value || e?.message || 'Falha ao salvar.', life: 4500 }); } }; } // ─────────────────────────────────────────────────────────────────────────── // onDialogDelete — porte direto de AgendaTerapeutaPage:2118-2204 // ─────────────────────────────────────────────────────────────────────────── function _buildOnDialogDelete(deps) { const { toast, ownerId, clinicTenantId, create, update, remove, cancelRule, cancelRuleFrom, upsertException, dialogOpen, dialogEventRow, eventsError, _reloadRange } = deps; return async function onDialogDelete(arg) { const id = typeof arg === 'string' ? arg : arg?.id; const editMode = typeof arg === 'string' ? null : arg?.editMode; const recurrenceId = typeof arg === 'string' ? null : (arg?.recurrence_id ?? arg?.serie_id ?? null); const originalDate = typeof arg === 'string' ? null : (arg?.original_date ?? dialogEventRow.value?.original_date ?? null); try { // ── Somente este evento / ocorrência ── if (!recurrenceId || editMode === 'somente_este') { if (originalDate && recurrenceId) { await upsertException({ recurrence_id: recurrenceId, tenant_id: clinicTenantId.value, original_date: originalDate, type: 'cancel_session' }); } else if (id) { await remove(id); } toast.add({ severity: 'success', summary: 'Excluído', detail: 'Sessão removida.', life: 2500 }); dialogOpen.value = false; await _reloadRange(); return; } // ── Este e os seguintes ── if (editMode === 'este_e_seguintes' && originalDate) { await cancelRuleFrom(recurrenceId, originalDate); toast.add({ severity: 'success', summary: 'Excluído', detail: 'Esta sessão e as seguintes foram canceladas.', life: 2500 }); dialogOpen.value = false; await _reloadRange(); return; } // ── Todos (encerrar série, manter sessão atual como avulsa) ── if (editMode === 'todos') { const row = dialogEventRow.value || {}; const isVirtual = row.is_occurrence && !id; if (isVirtual) { const rDate = row.original_date || row.inicio_em?.slice(0, 10); const existing = await supabase .from('agenda_eventos') .select('id') .eq('recurrence_id', recurrenceId) .eq('recurrence_date', rDate) .maybeSingle(); if (existing.data?.id) { await update(existing.data.id, { recurrence_id: null, recurrence_date: null }); } else { await create({ owner_id: ownerId.value, tenant_id: clinicTenantId.value, tipo: row.tipo || 'sessao', status: row.status || 'agendado', inicio_em: row.inicio_em, fim_em: row.fim_em, 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, observacoes: row.observacoes || null, visibility_scope: 'public' }); } } else if (id) { await update(id, { recurrence_id: null, recurrence_date: null }); } await cancelRule(recurrenceId); toast.add({ severity: 'success', summary: 'Série encerrada', detail: 'A série foi encerrada. Esta sessão foi mantida como avulsa.', life: 3000 }); dialogOpen.value = false; await _reloadRange(); return; } // fallback if (id) await remove(id); toast.add({ severity: 'success', summary: 'Excluído', detail: 'Compromisso removido.', life: 2500 }); dialogOpen.value = false; await _reloadRange(); } catch (e) { toast.add({ severity: 'warn', summary: 'Erro', detail: eventsError.value || e?.message || 'Falha ao excluir.', life: 4500 }); } }; } // ─────────────────────────────────────────────────────────────────────────── // Helper: oferece geração de billing_contract após criar série recorrente // com serviços. Chamado APÓS fechar o dialog principal. // ─────────────────────────────────────────────────────────────────────────── async function _offerBillingContract({ normalized, recorrencia, tenantId, confirm, toast }) { const n = recorrencia.qtdSessoes; const items = recorrencia.commitmentItems || []; const totalPorSessao = items.reduce((s, i) => s + (i.final_price ?? 0), 0); const pacoteFechado = recorrencia.serieValorMode === 'dividir'; const packagePrice = pacoteFechado ? totalPorSessao : totalPorSessao * n; const perSessao = pacoteFechado ? totalPorSessao / n : totalPorSessao; const fmtB = (v) => Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); return new Promise((resolve) => { confirm.require({ header: 'Gerar contrato de cobrança?', message: `${n} sessões — ${fmtB(perSessao)} por sessão. Total da série: ${fmtB(packagePrice)}.`, icon: 'pi pi-file', acceptLabel: 'Sim, gerar contrato', rejectLabel: 'Agora não', accept: async () => { try { const { error } = await supabase.from('billing_contracts').insert({ owner_id: normalized.owner_id, tenant_id: tenantId, patient_id: normalized.paciente_id, type: 'package', total_sessions: n, sessions_used: 0, package_price: packagePrice, status: 'active' }); if (error) throw error; toast.add({ severity: 'success', summary: 'Contrato gerado', detail: `Pacote de ${n} sessões: ${fmtB(packagePrice)}.`, life: 3000 }); } catch (e) { toast.add({ severity: 'warn', summary: 'Erro ao gerar contrato', detail: e?.message, life: 4000 }); } resolve(); }, reject: () => resolve() }); }); }