Files
agenciapsilmno/src/layout/melissa/composables/useMelissaAgenda.js
T
Leonardo 957e912a7f Melissa polish + Prontuario Visao Geral + agenda historico
Sprints B (05-03) e C (05-04) acumulados:

- NotificationDrawer/Item redesign (visual mais limpo, ações inline)
- Dock pins compose (useMelissaDockPins) + cache store global (melissaCacheStore)
- MelissaAgenda: timeline FullCalendar parity + cards resumo, histórico
  card com useMelissaAgendaHistorico, MelissaEventoPanel ajustado
- useFeriados: cache opt-in pra evitar fetch redundante de feriados
- PatientProntuario: aba Visão Geral nova; PatientConversationsTab polish
- AgendaClinicMosaic / AgendaTerapeutaPage / useAgendaSettings: ajustes
  de paridade com Melissa
- DocumentsListPage: pequenos ajustes
- DB migration 20260504000001: fix do trigger pra status 'excluido' nas
  cancel_notifications

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 09:11:55 -03:00

1387 lines
66 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, onBeforeUnmount } 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 ────────────────────────────────────
// cache: stale-while-revalidate via melissaCacheStore — abertura
// subsequente da Agenda na mesma sessão usa cache instantâneo.
const { settings, workRules, load: loadSettings } = useAgendaSettings({ cache: true });
// _bootUid: pegado em paralelo no mount via supabase.auth.getUser().
// Sem isso, ownerId ficava null até loadSettings completar (~300ms),
// bloqueando o primeiro fetch dos eventos. Como owner_id da agenda
// é literalmente o uid do user logado, podemos resolver imediato.
const _bootUid = ref('');
const ownerId = computed(() => settings.value?.owner_id || _bootUid.value || '');
// ── 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 ──────────────────────────
// Instância única de useFeriados — antes MelissaAgenda.vue criava
// sua própria também, fazendo dupla requisição de feriados municipais
// toda vez que a agenda abria. Agora MelissaAgenda lê esses refs do
// composable injetado (M.feriadosAno, M.loadFeriadosBase, etc).
const {
todos: feriados,
fcEvents: feriadoFcEvents,
load: loadFeriadosBase,
ano: feriadosAno
} = useFeriados({ cache: true });
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;
// Espera ownerId E tenant — qualquer um faltando significa boot
// ainda em curso (auth/tenantStore/settings async). Watcher one-shot
// re-dispara assim que o último ficar disponível, sem polling.
if (!ownerId.value || !clinicTenantId.value) {
const unwatch = watch(
() => [ownerId.value, clinicTenantId.value],
([uid, tid]) => {
if (!uid || !tid) return;
unwatch();
_reloadRange();
}
);
return;
}
const start = new Date(s);
const end = new Date(e);
const tid = clinicTenantId.value;
// Etapa 1: eventos reais — `rows` é reativo, FullCalendar re-renderiza
// assim que esse await resolve (o user já vê as sessões agendadas).
await loadMyRange(start.toISOString(), end.toISOString(), ownerId.value);
// Etapa 2: ocorrências virtuais (regras de recorrência expandidas).
// Continuamos awaitando porque saveRule/cancel dependem do estado
// final estar pronto pra UI consistente, mas a janela visual onde
// o usuário vê só eventos reais é a metade do tempo de antes.
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 ───────────────────────────────────────────
// Boot paralelo: auth uid + tenant + settings todos disparam ao mesmo
// tempo. Antes era serial (loadSettings precisava terminar pra ownerId
// ficar disponível e o watch disparar _reloadRange) — adicionava ~300ms
// de waterfall antes da primeira query de eventos sair.
onMounted(() => {
// 1) Resolve o uid o quanto antes — destrava _reloadRange.
// getSession() lê do storage local (fast path, <10ms);
// getUser() faria round-trip pro auth server. Fallback pro
// getUser só se a sessão ainda não estiver no storage.
supabase.auth.getSession()
.then(({ data }) => {
const uid = data?.session?.user?.id;
if (uid) {
_bootUid.value = uid;
} else {
// Cold start sem sessão hidratada — fallback pro round-trip.
return supabase.auth.getUser().then(({ data: u }) => {
if (u?.user?.id) _bootUid.value = u.user.id;
});
}
})
.catch(() => { /* noop — settings ainda pode resolver */ });
// 2) Garante que o tenant está hidratado (idempotente — se já
// estiver carregado, retorna imediato).
if (typeof tenantStore.ensureLoaded === 'function') {
tenantStore.ensureLoaded().catch(() => {});
}
// 3) Settings em paralelo (não bloqueia mais nada)
loadSettings();
});
// Refetch settings + workRules quando o user salva jornada/ritmo/online
// em /configuracoes/agenda (embedado no Melissa). Sem isso, a timeline
// do resumo continuaria mostrando o range antigo até reload da página.
function _onSettingsSaved() {
loadSettings();
}
onMounted(() => {
window.addEventListener('agenda:settings-saved', _onSettingsSaved);
});
onBeforeUnmount(() => {
window.removeEventListener('agenda:settings-saved', _onSettingsSaved);
});
// Commitments + feriados dependem do tenant. Em refresh "frio", o
// tenantStore ainda não terminou de hidratar quando o composable
// monta, e clinicTenantId fica null. loadDeterminedCommitments faz
// bail-out silencioso quando tenantId é vazio (rows = [], sem retry)
// — daí o "às vezes" do bug onde commitmentOptions chegava vazio no
// AgendaEventDialog. Watch com immediate: true dispara já se o tenant
// estiver pronto, ou no momento exato em que ele aparecer.
watch(
clinicTenantId,
async (tid) => {
if (!tid) return;
await loadDeterminedCommitments();
await loadFeriadosBase(tid);
},
{ immediate: true }
);
// Reload quando o range visível muda. _reloadRange já tem guard
// interno pra esperar uid+tenant (one-shot watcher) — sem necessidade
// de outro watch global em ownerId, que disparava _reloadRange duplicado.
watch([viewStart, viewEnd], _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,
feriadosAno,
loadFeriadosBase,
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()
});
});
}