Files
agenciapsilmno/src/layout/melissa/composables/useMelissaAgenda.js
T
Leonardo ffcb8b17f9 Melissa Agenda: paridade com AgendaTerapeuta + responsivo mobile
Composable useMelissaAgenda (~1150 linhas, exclusivo Melissa):
- Orquestra useAgendaEvents + useRecurrence + useDeterminedCommitments
  + useFeriados + useCommitmentServices
- 7 cases de save (avulso, recorrente C, somente_este D, este_e_seguintes E,
  todos F, todos_sem_excecao G + tratamento de exclusion constraint)
- 3 cases de delete (somente_este, este_e_seguintes, todos com encerrar série)
- onCreateEvento (botão Agendar), onSelectTime com cap de 120min,
  persistMoveOrResize com confirm dialog descritivo e bold em datas/horas
- Bloqueio: openBloqueioDialog(mode) com 4 modos

MelissaLayout:
- Provide composable via MELISSA_AGENDA_KEY (inject em MelissaAgenda)
- Renderiza AgendaEventDialog + BloqueioDialog + ConfirmDialog
- Slot #message v-html pra renderizar HTML em messages do confirm
- onEditEvento liga panel ao dialog completo (B3 não-stub)

MelissaAgenda:
- Drop useMelissaEventosRange — eventos vêm do composable injetado
- Drag/resize/select-to-create habilitados quando há composable
- Cluster Paciente + Agendar (50/50 primary)
- Toolbar: timeMode (24/12/Meu) + onlySessions + bloquear-menu (desktop)
- Header: Pacientes (mobile-only, abre drawer) + Configurações + Fechar
- Mobile <lg: aside + widgets viram drawer off-canvas (slide esquerda);
  calendar fullwidth; "Ações" menu mobile concentra timeMode/onlySessions/
  bloquear; backdrop com click-outside

MelissaEventoPanel (B3 estático-revisado):
- Substitui panel inline que crashava em campos inexistentes
- Action bar agrupada (status / paciente / geral)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 18:15:56 -03:00

1304 lines
62 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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 `<Toast />` e `<ConfirmDialog />`.
*/
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 10240; 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ── 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 <strong>. 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 ` +
`<strong>${_fmtH(oldStart)}</strong> para ` +
`<strong>${_fmtH(start)}</strong>. 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 ` +
`<strong>${_fmtD(oldStart)} ${_fmtH(oldStart)}</strong> para ` +
`<strong>${_fmtD(start)} ${_fmtH(start)}</strong>. 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 ` +
`<strong>${oldDur}min</strong> para ` +
`<strong>${newDur}min</strong>. 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()
});
});
}