/*
* useMelissaAgenda — Orquestrador da Agenda dentro do Layout Melissa.
* --------------------------------------------------
* Equivalente Melissa-exclusivo de AgendaTerapeutaPage.vue: carrega settings,
* eventos reais (useAgendaEvents), expande recorrências (useRecurrence),
* resolve compromissos determinados (catálogo) e feriados, mantém o estado
* do dialog de edição, e expõe TODOS os handlers que o dialog/FC precisam:
* onDialogSave / onDialogDelete — CRUD com 7 cases (avulso, recorrente,
* somente_este, este_e_seguintes, todos,
* todos_sem_excecao)
* onUpdateSeriesEvent — mudança de status numa ocorrência
* onEditSeriesOccurrence — preview ao alternar editScope
* persistMoveOrResize — drag/resize do FC com checagem de conflito
* onSelectTime — click-drag pra criar evento novo
* onEditEvento — popula dialogEventRow a partir do raw row
*
* Eventos retornados em `eventos` são Melissa-shape (color, label, startH/endH,
* dateKey, _raw) — `_raw` carrega o registro bruto + flags de ocorrência virtual
* pra alimentar o AgendaEventDialog.
*
* Cria refs `viewStart`/`viewEnd` internamente — MelissaAgenda escreve neles
* no datesSet do FC (via provide/inject). O composable observa e refetcha.
*
* Os handlers exibem toasts (success/warn) — o composable assume que os
* componentes consumidores já registraram `` e ``.
*/
import { ref, computed, watch, onMounted, 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';
import { useAgendaBloqueios } from '@/features/agenda/composables/useAgendaBloqueios';
// ─── Constantes do domínio (espelhadas de AgendaTerapeutaPage) ──────────────
const EVENTO_TIPO = Object.freeze({ SESSAO: 'sessao', BLOQUEIO: 'bloqueio' });
const EVENTO_TIPOS_VALIDOS = new Set([EVENTO_TIPO.SESSAO, EVENTO_TIPO.BLOQUEIO]);
// Limite máximo de duração de sessão (regra de negócio Melissa). DB constraint
// `session_duration_min_chk` permite 10–240; convencionamos 120 (2h) aqui pra
// evitar slots gigantes acidentais. Futuro: ler de `agenda_configuracoes` se
// `max_session_duration_min` for adicionado.
const MAX_SESSION_MINUTES = 120;
function isUuid(v) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(v || ''));
}
function normalizeEventoTipo(t, fallback = EVENTO_TIPO.SESSAO) {
const s = String(t || '').trim().toLowerCase();
if (!s) return fallback;
if (s.includes('sess')) return EVENTO_TIPO.SESSAO;
if (s.includes('bloq') || s.includes('ocup')) return EVENTO_TIPO.BLOQUEIO;
return EVENTO_TIPOS_VALIDOS.has(s) ? s : fallback;
}
function deriveEventoTipoForNewEvent(payload) {
const vis = String(payload?.visibility_scope || '').toLowerCase();
const title = String(payload?.titulo || '').toLowerCase();
if (vis === 'busy_only' || title.includes('ocup')) return EVENTO_TIPO.BLOQUEIO;
return EVENTO_TIPO.SESSAO;
}
function deriveTituloDefaultByTipo(tipo) {
return normalizeEventoTipo(tipo, EVENTO_TIPO.SESSAO) === EVENTO_TIPO.BLOQUEIO ? 'Ocupado' : 'Sessão';
}
function pickDbFields(obj) {
const allowed = [
'tenant_id', 'owner_id', 'terapeuta_id', 'patient_id',
'tipo', 'status', 'titulo', 'observacoes', 'modalidade',
'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, paymentStateMap = null, paymentAmountMap = null, rulePaymentMap = null, ruleContractMap = null) {
// 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);
// Estado de pagamento — lido do _paymentStateMap (preenchido pelo
// _reloadRange via bulk-query em financial_records). Default 'none'
// pra ocorrências virtuais (sem id real ainda) e eventos sem record.
// 'paid' | 'pending' | 'none'.
// Fallback pra virtuais ou reais sem record: herda do contrato upfront
// pago da série via _rulePaymentMap (chave = recurrence_id).
let paymentState =
(!isOccurrence && r.id && paymentStateMap && paymentStateMap[r.id]) || null;
let paymentAmount =
!isOccurrence && r.id && paymentAmountMap ? (paymentAmountMap[r.id] ?? null) : null;
if ((!paymentState || paymentState === 'none') && r.recurrence_id && rulePaymentMap && rulePaymentMap[r.recurrence_id]) {
paymentState = rulePaymentMap[r.recurrence_id].state;
if (paymentAmount == null) paymentAmount = rulePaymentMap[r.recurrence_id].amount;
}
if (!paymentState) paymentState = 'none';
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,
paymentState,
paymentAmount,
// Info do contrato (saldo/upfront) — injetado quando a série tem
// billing_contract ativo. Popover usa pra mostrar "Pacote X · N/M".
contract: r.recurrence_id && ruleContractMap ? (ruleContractMap[r.recurrence_id] ?? null) : null,
price: r.price != null ? Number(r.price) : null,
// insurance_value: pra convênio, o valor cobrado mora aqui (não em
// price). Popover e Resumo usam fallback `price ?? insurance_value`
// pra mostrar o valor da cobrança independente do tipo.
insurance_value: r.insurance_value != null ? Number(r.insurance_value) : 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();
// Bloqueios — renderizados como background events cinza no FullCalendar
// (entidade própria, distinta de agenda_eventos). Carregados via range
// visível + recorrentes; reativo a viewStart/viewEnd via _reloadRange.
const { bloqueios, load: loadBloqueios, buildEventsForRange: buildBloqueioEvents } = useAgendaBloqueios();
const bloqueioFcEvents = computed(() => {
const s = viewStart.value;
const e = viewEnd.value;
if (!s || !e) return [];
return buildBloqueioEvents(s, e);
});
// Detecta se um range [start, end] cai dentro de algum bloqueio carregado.
// Cobre bloqueios dia-inteiro (sem hora), com janela horária e recorrentes
// semanais (via dia_semana). Usado pra avisar o terapeuta quando ele tenta
// criar sessão em cima de bloqueio próprio — não impede, só sinaliza.
function bloqueioCobrindo(start, end) {
const arr = bloqueios?.value || [];
if (!arr.length || !start) return null;
const dStart = start instanceof Date ? start : new Date(start);
const dEnd = end instanceof Date ? end : new Date(end || start);
const isoDay = `${dStart.getFullYear()}-${String(dStart.getMonth() + 1).padStart(2, '0')}-${String(dStart.getDate()).padStart(2, '0')}`;
const dow = dStart.getDay();
const hhmmStart = dStart.getHours() * 60 + dStart.getMinutes();
const hhmmEnd = dEnd.getHours() * 60 + dEnd.getMinutes();
const parseHM = (s) => {
if (!s) return null;
const [h, m] = String(s).split(':').map(Number);
return Number.isFinite(h) ? h * 60 + (m || 0) : null;
};
for (const b of arr) {
if (!b) continue;
if (b.recorrente && b.dia_semana != null) {
if (Number(b.dia_semana) !== dow) continue;
} else {
const di = b.data_inicio;
const df = b.data_fim || b.data_inicio;
if (!di) continue;
if (isoDay < di || isoDay > df) continue;
}
const bhi = parseHM(b.hora_inicio);
const bhf = parseHM(b.hora_fim);
if (bhi == null || bhf == null) return b;
if (hhmmStart < bhf && hhmmEnd > bhi) return b;
}
return null;
}
// ── Linhas combinadas (real + virtual) ──────────────────────
const allRows = computed(() => [...(rows.value || []), ...(_occurrenceRows.value || [])]);
// Map de estado de pagamento por agenda_evento_id, preenchido pelo
// _reloadRange via bulk-query em financial_records. Permite renderizar
// badge "$ pendente" no FC e linha "A receber" no popover sem ter que
// queryar por evento. 'paid' | 'pending'. Eventos não listados = 'none'.
const _paymentStateMap = ref({});
// Map evento_id → valor pago/cobrado (final_amount do record). Usado pelo
// popover e Resumo do dialog quando paymentState='paid' pra mostrar o
// valor REAL pago (vs evento.price que pode ter sido editado depois).
const _paymentAmountMap = ref({});
// Map recurrence_id → {state, amount}. Pra ocorrências virtuais (que não
// têm id real e portanto não entram em _paymentStateMap), normalize lê
// daqui o estado herdado do contrato upfront pago da série.
const _rulePaymentMap = ref({});
// Map recurrence_id → {style, totalSessions, sessionsUsed, packagePrice}
// — info do billing_contract da série pra exibir no popover.
const _ruleContractMap = ref({});
// ── Eventos Melissa-normalizados (consumidos pelo MelissaAgenda FC) ──
const eventos = computed(() => allRows.value.map((r) => normalizeForMelissa(r, _paymentStateMap.value, _paymentAmountMap.value, _rulePaymentMap.value, _ruleContractMap.value)));
// ── 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('');
// Aviso quando o slot escolhido cai em cima de um bloqueio próprio.
// Renderizado pelo AgendaEventDialog no topo do step 1 (Message warn).
// null = sem aviso; { titulo } = aviso ativo.
const dialogBlockOverlap = ref(null);
// Segundo dialog (empilhado por cima do principal) — usado pra editar
// uma OCORRÊNCIA específica de uma série. Acionado pelo botão "Editar"
// de cada pill na lista "Recorrências Aplicadas" do AgendaEventDialog.
// Adicionado 2026-05-11 — espelho pra Rail/Clínica está pendente.
const occDialogOpen = ref(false);
const occDialogEventRow = ref(null);
const occDialogStartISO = ref('');
const occDialogEndISO = ref('');
// serieRefreshTick: bump quando o 2º dialog (ocorrencia) faz save com
// sucesso. AgendaEventDialog do dialog PAI observa essa prop e re-roda
// loadSerieEvents pra atualizar a lista de pills "Recorrencias Aplicadas".
// Sem isso, pills ficavam com status stale ate fechar/reabrir o pai. 2026-05-12.
const serieRefreshTick = ref(0);
// Status change confirm dialog (Fase 5, 2026-05-14) — aparece quando
// user muda status pra realizado/faltou/cancelado e há decisão a tomar
// (regra de exceção, pacote saldo, pending record).
const statusDialogOpen = ref(false);
const statusDialogProps = ref({});
// Resolver guardado fora do ref pra não vazar pro template via reactivity.
let _statusDialogResolve = null;
function _openStatusDialog(propsObj) {
return new Promise((resolve) => {
statusDialogProps.value = propsObj;
statusDialogOpen.value = true;
_statusDialogResolve = resolve;
});
}
function onStatusDialogConfirm(decision) {
if (_statusDialogResolve) _statusDialogResolve(decision);
_statusDialogResolve = null;
}
function onStatusDialogCancel() {
// PrimeVue Dialog dispara update:visible(false) ao fechar via X/Esc;
// tratar isso aqui ainda — resolve com null pra cancelar.
if (_statusDialogResolve) _statusDialogResolve(null);
_statusDialogResolve = null;
}
// 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);
// Etapa 3: bloqueios (background events). Não bloqueia render do
// calendário — load assíncrono em paralelo. bloqueioFcEvents é computed
// e re-renderiza quando bloqueios.value atualizar.
loadBloqueios(ownerId.value, start, end);
// Etapa 4: estado de pagamento — bulk-query 1x pra todos os eventos
// reais visíveis. Annotação 'paid' | 'pending' fica no _paymentStateMap;
// o computed `eventos` re-corre normalizeForMelissa quando o map muda.
// Não bloqueia render — eventos aparecem sem badge até este await
// resolver (default 'none').
const realIds = (rows.value || []).map((r) => r.id).filter(Boolean);
// Inicializa maps SEMPRE (não condicional a realIds.length>0) — em
// semanas onde só há virtuais (sem eventos reais), ainda precisamos
// rodar a propagação cross-week pra ruleMap.
const map = {};
const amountMap = {};
const ruleMap = {};
const ruleContractMap = {};
// Filtra cancelados: cobrança cancelada não deve manter
// paymentState='pending' (badge $ residual). Tratamos cancelled
// como "sem cobrança ativa" → cai pro default 'none'.
if (realIds.length) {
const { data: recs } = await supabase
.from('financial_records')
.select('agenda_evento_id, paid_at, status, amount, final_amount')
.in('agenda_evento_id', realIds)
.neq('status', 'cancelled');
for (const id of realIds) map[id] = 'none';
for (const rec of recs || []) {
const eid = rec.agenda_evento_id;
if (!eid) continue;
if (rec.paid_at) {
// Paid override: se QUALQUER record dessa sessão está paid,
// consideramos cobrança honrada. Filhos (multas/taxas) que
// venham pendentes não revertem esse estado pro badge.
map[eid] = 'paid';
amountMap[eid] = rec.final_amount ?? rec.amount ?? null;
} else if (map[eid] !== 'paid') {
map[eid] = 'pending';
if (amountMap[eid] == null) amountMap[eid] = rec.final_amount ?? rec.amount ?? null;
}
}
}
// Propagação de pacote UPFRONT — SEMPRE roda (mesmo sem eventos reais
// na view), pra que virtuais isoladas em semanas futuras herdem o
// estado do contrato cross-week. Antes estava dentro de
// `if (realIds.length)` e falhava em semanas só com virtuais.
//
// Quando a 1ª materializada
// de uma série tem record (paid OU pending), TODAS as ocorrências
// do mesmo recurrence_id devem herdar o mesmo estado (cobertas
// pelo contrato — cobrança única do pacote inteiro).
// Funciona pra real rows; virtuais ficam por loadOccFinancialRecord
// no occurrenceMode (não passam por este bulk).
// Estratégia: pega rules dos eventos com record OU dos eventos
// visíveis que têm recurrence_id (cross-week — record do pacote
// pode estar em outra semana), busca contratos upfront ativos
// pros pacientes, propaga estado pra siblings no view atual.
try {
// 1) Coleta rule_ids de TODOS os eventos visíveis que pertencem
// a uma série. Antes só usávamos eventos COM record paid/pending,
// o que falhava cross-week (record do pacote pode estar em
// outra semana). Agora propagação cobre Ana 2/3/4 mesmo
// quando o record está só no Ana 1.
const ruleIdsInView = new Set();
for (const r of rows.value || []) {
if (r.recurrence_id) ruleIdsInView.add(r.recurrence_id);
}
// Virtuais expandidas (que vêm depois) também trazem rules:
for (const r of _occurrenceRows.value || []) {
if (r.recurrence_id) ruleIdsInView.add(r.recurrence_id);
}
if (ruleIdsInView.size) {
// 2) Acha patient_id direto das rules (fonte autoritativa,
// funciona até quando rule não tem nenhum evento
// materializado — caso pacote saldo recém-criado).
const { data: rulesData } = await supabase
.from('recurrence_rules')
.select('id, patient_id')
.in('id', [...ruleIdsInView]);
const rulePatientFromRule = new Map();
for (const r of rulesData || []) {
if (r.patient_id) rulePatientFromRule.set(r.id, r.patient_id);
}
// 3) Acha eventos (em qualquer semana) das rules em view +
// seus records paid/pending pra detectar estado.
const { data: allRuleEvents } = await supabase
.from('agenda_eventos')
.select('id, recurrence_id, patient_id')
.in('recurrence_id', [...ruleIdsInView]);
const ruleEventIds = (allRuleEvents || []).map((e) => e.id);
let ruleRecords = [];
if (ruleEventIds.length) {
const { data: rr } = await supabase
.from('financial_records')
.select('agenda_evento_id, paid_at, status')
.in('agenda_evento_id', ruleEventIds)
.neq('status', 'cancelled');
ruleRecords = rr || [];
}
// Mapeia: rule_id → state (paid > pending > none) + patient
const evIdToRule = new Map();
const evIdToPatient = new Map();
for (const e of allRuleEvents || []) {
evIdToRule.set(e.id, e.recurrence_id);
evIdToPatient.set(e.id, e.patient_id);
}
const ruleToPatient = new Map();
const ruleToState = new Map();
for (const rec of ruleRecords) {
const rid = evIdToRule.get(rec.agenda_evento_id);
const pid = evIdToPatient.get(rec.agenda_evento_id);
if (!rid || !pid) continue;
ruleToPatient.set(rid, pid);
const newState = rec.paid_at ? 'paid' : 'pending';
const existing = ruleToState.get(rid);
if (existing !== 'paid') ruleToState.set(rid, newState);
}
// Busca contratos ativos pra TODOS pacientes envolvidos
// (saldo OU upfront — ambos exibem info no popover).
// Query única com todos campos necessários. Usa
// rulePatientFromRule (fonte autoritativa) pra cobrir
// saldo sem records (não passa por ruleToPatient).
const allPatientIds = [...new Set(rulePatientFromRule.values())];
let activePackages = [];
if (allPatientIds.length) {
const { data: contracts } = await supabase
.from('billing_contracts')
.select('id, patient_id, charging_style, status, type, total_sessions, sessions_used, package_price')
.in('patient_id', allPatientIds);
activePackages = (contracts || []).filter((c) => c.type === 'package' && c.status === 'active');
}
// Index por patient_id pra lookup rápido
const contractByPatient = new Map();
for (const c of activePackages) contractByPatient.set(c.patient_id, c);
// Popula ruleContractMap pra TODAS as rules em view com
// contrato ativo (saldo + upfront, com OU sem records).
for (const [rid, pid] of rulePatientFromRule.entries()) {
const c = contractByPatient.get(pid);
if (c) {
ruleContractMap[rid] = {
id: c.id,
style: c.charging_style || 'upfront',
totalSessions: c.total_sessions || 0,
sessionsUsed: c.sessions_used || 0,
packagePrice: Number(c.package_price || 0)
};
}
}
if (ruleToPatient.size) {
// NULL charging_style → assume upfront (default histórico
// antes da migration 20260514000003). Pra dados antigos
// sem a coluna preenchida, evita virtuais ficarem sem
// propagação. 'saldo' explícito mantém siblings 'none'.
const upfrontPatients = new Set(
activePackages
.filter((c) => c.charging_style === 'upfront' || c.charging_style == null)
.map((c) => c.patient_id)
);
const ruleIdsToPropagate = [...ruleToPatient.entries()]
.filter(([, pid]) => upfrontPatients.has(pid))
.map(([rid]) => rid);
if (ruleIdsToPropagate.length) {
const { data: siblings } = await supabase
.from('agenda_eventos')
.select('id, recurrence_id')
.in('recurrence_id', ruleIdsToPropagate);
// Reusa contractByPatient da query unificada acima
// (antes havia uma 2ª query redundante pro mesmo dado).
const contractPriceByPatient = new Map();
for (const c of activePackages) {
if (c.charging_style === 'upfront' || c.charging_style == null) {
contractPriceByPatient.set(c.patient_id, c.package_price);
}
}
for (const s of siblings || []) {
if (map[s.id] !== undefined) {
const stateToPropagate = ruleToState.get(s.recurrence_id) || 'pending';
// Não rebaixa: se sibling já está paid
// (record próprio paid), mantém.
if (map[s.id] !== 'paid') map[s.id] = stateToPropagate;
const pid = ruleToPatient.get(s.recurrence_id);
const price = contractPriceByPatient.get(pid);
if (price != null) amountMap[s.id] = price;
}
}
// Populate _rulePaymentMap pra que VIRTUAIS (ainda
// não materializadas) também herdem o estado ao
// passar pelo normalize.
for (const rid of ruleIdsToPropagate) {
const pid = ruleToPatient.get(rid);
const price = contractPriceByPatient.get(pid);
const state = ruleToState.get(rid) || 'pending';
ruleMap[rid] = { state, amount: price ?? null };
}
}
}
}
} catch (e) {
console.warn('[useMelissaAgenda] propagação upfront falhou:', e?.message);
}
_paymentStateMap.value = map;
_paymentAmountMap.value = amountMap;
_rulePaymentMap.value = ruleMap;
_ruleContractMap.value = ruleContractMap;
}
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,
occDialogOpen, occDialogEventRow, occDialogStartISO, occDialogEndISO,
serieRefreshTick,
// Fase 5: dialog de status change vive no escopo do composable
// (refs ficam aqui pra expor pro template); _buildHandlers precisa
// do opener pra abrir e aguardar decisão antes de aplicar.
_openStatusDialog,
_reloadRange,
bloqueioCobrindo,
dialogBlockOverlap
}));
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,
dialogBlockOverlap,
// 2º dialog (edit ocorrência da série, empilhado por cima)
occDialogOpen,
occDialogEventRow,
occDialogStartISO,
occDialogEndISO,
serieRefreshTick,
// Bloqueio dialog
bloqueioDialogOpen,
bloqueioMode,
openBloqueioDialog,
// Status change confirm dialog (Fase 5)
statusDialogOpen,
statusDialogProps,
onStatusDialogConfirm,
onStatusDialogCancel,
// Settings + commitments + feriados (props pro AgendaEventDialog)
settings,
workRules,
ownerId,
clinicTenantId,
commitmentOptions,
feriados,
feriadoFcEvents,
feriadosAno,
loadFeriadosBase,
allEventsForDialog,
// Bloqueios (background events cinza no FC)
bloqueios,
bloqueioFcEvents,
// 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,
occDialogOpen, occDialogEventRow, occDialogStartISO, occDialogEndISO,
_openStatusDialog,
bloqueioCobrindo,
dialogBlockOverlap
} = deps;
// Helpers de formatação pra mensagens de confirm/toast (PT-BR estilo
// "16h" / "15:15h" / "27/04").
function _fmtH(d) {
if (!d) return '?';
const h = d.getHours();
const m = d.getMinutes();
return m === 0 ? `${h}h` : `${h}:${String(m).padStart(2, '0')}h`;
}
function _fmtD(d) {
if (!d) return '';
return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}`;
}
// Escape HTML — usado quando a mensagem do confirm tem partes
// controladas pelo usuário (nome do paciente) e v-html no slot.
function _esc(s) {
return String(s ?? '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"');
}
// ── onEditEvento — chamado pelo MelissaEventoPanel ao clicar "Editar" ──
// Recebe o `_raw` normalizado e popula o dialogEventRow no shape esperado
// pelo AgendaEventDialog (mesma estrutura de AgendaTerapeutaPage.onEventClick).
function onEditEvento(rawRow) {
if (!rawRow) return;
dialogEventRow.value = {
id: rawRow.is_occurrence ? null : rawRow.id || null,
owner_id: rawRow.owner_id ?? ownerId.value,
terapeuta_id: rawRow.terapeuta_id ?? null,
paciente_id: rawRow.patient_id ?? rawRow.paciente_id ?? null,
paciente_nome: rawRow.paciente_nome ?? rawRow.patient_name ?? rawRow.patients?.nome_completo ?? null,
paciente_avatar: rawRow.paciente_avatar ?? rawRow.patients?.avatar_url ?? null,
paciente_status: rawRow.paciente_status ?? rawRow.patients?.status ?? null,
tipo: rawRow.tipo || 'sessao',
status: rawRow.status,
titulo: rawRow.titulo,
observacoes: rawRow.observacoes ?? null,
visibility_scope: rawRow.visibility_scope ?? 'public',
inicio_em: rawRow.inicio_em,
fim_em: rawRow.fim_em,
modalidade: rawRow.modalidade ?? null,
determined_commitment_id: rawRow.determined_commitment_id ?? null,
titulo_custom: rawRow.titulo_custom ?? null,
extra_fields: rawRow.extra_fields ?? null,
price: rawRow.price != null ? Number(rawRow.price) : null,
insurance_plan_id: rawRow.insurance_plan_id ?? null,
insurance_guide_number: rawRow.insurance_guide_number ?? null,
insurance_value: rawRow.insurance_value != null ? Number(rawRow.insurance_value) : null,
insurance_plan_service_id: rawRow.insurance_plan_service_id ?? null,
// recorrência
recurrence_id: rawRow.recurrence_id ?? null,
original_date: rawRow.original_date ?? rawRow.recurrence_date ?? null,
is_occurrence: !!rawRow.is_occurrence,
exception_type: rawRow.exception_type ?? rawRow.exceptionType ?? null,
// legado
serie_id: rawRow.serie_id ?? rawRow.recurrence_id ?? null,
serie_dia_semana: rawRow.serie_dia_semana ?? null,
serie_hora: rawRow.serie_hora ?? null
};
dialogStartISO.value = '';
dialogEndISO.value = '';
dialogBlockOverlap.value = null;
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();
dialogBlockOverlap.value = null;
dialogOpen.value = true;
}
// ── onCreateEventoForPatient — abre o AgendaEventDialog com paciente
// pre-selecionado. Usado pelo MelissaPaciente quando o user clica
// "Agendar" na sidebar Acoes Rapidas. Mesma logica de onCreateEvento
// (defaults razoaveis: hoje proximo slot 15min, duracao default), so
// que injeta paciente_id no dialogEventRow.
function onCreateEventoForPatient(patientId) {
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();
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: patientId ? String(patientId) : 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();
dialogBlockOverlap.value = null;
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();
// Aviso de bloqueio — não veta criação (só agendador público veta),
// mas sinaliza pro terapeuta que tá agendando em cima de bloqueio.
// Renderizado como Message no topo do step 1 do AgendaEventDialog
// (toast antigo ficava atrás do overlay do dialog).
const bloqHit = bloqueioCobrindo(rawStart, new Date(rawStart.getTime() + durMin * 60000));
dialogBlockOverlap.value = bloqHit ? { titulo: bloqHit.titulo || 'Bloqueio' } : null;
dialogEventRow.value = {
owner_id: ownerId.value,
terapeuta_id: null,
paciente_id: null,
tipo: EVENTO_TIPO.SESSAO,
status: 'agendado',
titulo: deriveTituloDefaultByTipo(EVENTO_TIPO.SESSAO),
observacoes: null,
visibility_scope: 'public',
determined_commitment_id: null
};
dialogStartISO.value = startISO;
dialogEndISO.value = endISO;
dialogOpen.value = true;
}
// ── persistMoveOrResize — drag/resize do FC ──
// Fluxo:
// 1. Bail se for ocorrência virtual (sem id real)
// 2. Conflict-check ANTES de confirmar (revert+toast imediato evita
// sequência ruim de "confirma? > ah não, conflito")
// 3. Confirm dialog descrevendo a alteração ("trocando Leonardo de
// 16h para 15:15h, confirma?")
// 4. Se aceitar, UPDATE + toast success. Se recusar, revert.
async function persistMoveOrResize(info, actionLabel) {
try {
const ev = info?.event;
if (!ev) return;
const id = ev.id;
if (!id || !isUuid(id)) {
info?.revert?.();
toast.add({
severity: 'info',
summary: 'Sessão recorrente',
detail: 'Para mover uma sessão da série, abra-a e edite com "Somente esta sessão".',
life: 4500
});
return;
}
const startISO = ev.start ? ev.start.toISOString() : null;
const endISO = ev.end ? ev.end.toISOString() : null;
if (!startISO || !endISO) throw new Error('Compromisso sem start/end.');
const start = new Date(startISO);
const end = new Date(endISO);
const breakMin = settings.value?.session_break_min || 0;
const conflict = allRows.value.find((r) => {
if (!r.inicio_em || r.id === id) return false;
const rS = new Date(r.inicio_em);
const rE = new Date(r.fim_em || r.inicio_em);
return start < new Date(rE.getTime() + breakMin * 60000) && end > rS;
});
if (conflict) {
info?.revert?.();
toast.add({ severity: 'warn', summary: 'Conflito', detail: 'Já existe um compromisso neste horário.', life: 4000 });
return;
}
// Confirm: monta mensagem descrevendo o que mudou. Usa HTML
// (renderizado via v-html no slot #message do ConfirmDialog em
// MelissaLayout) pra destacar datas/horas em . Subject
// é escapado pra evitar XSS via nome de paciente.
const oldStart = info.oldEvent?.start || null;
const oldEnd = info.oldEvent?.end || null;
const ext = ev.extendedProps || {};
const subject = ext.pacienteNome || ext.label || ext.titulo || 'evento';
const subjectEsc = _esc(subject);
const startMoved = oldStart && start.getTime() !== oldStart.getTime();
const endMoved = oldEnd && end.getTime() !== oldEnd.getTime();
let message;
if (startMoved) {
const sameDay = oldStart.toDateString() === start.toDateString();
if (sameDay) {
// Mudou só hora (mesmo dia)
message =
`Está trocando o horário de ${subjectEsc} de ` +
`${_fmtH(oldStart)} para ` +
`${_fmtH(start)}. Confirma?`;
} else {
// Mudou de dia (com ou sem mudança de hora) — destaca
// data E hora em ambos os lados
message =
`Está movendo ${subjectEsc} de ` +
`${_fmtD(oldStart)} ${_fmtH(oldStart)} para ` +
`${_fmtD(start)} ${_fmtH(start)}. Confirma?`;
}
} else if (endMoved) {
// Só resize — mostra duração antiga → nova
const oldDur = Math.round((oldEnd.getTime() - oldStart.getTime()) / 60000);
const newDur = Math.round((end.getTime() - start.getTime()) / 60000);
message =
`Está alterando a duração de ${subjectEsc} de ` +
`${oldDur}min para ` +
`${newDur}min. Confirma?`;
} else {
message = `Confirmar alteração de ${subjectEsc}?`;
}
const accepted = await new Promise((resolve) => {
confirm.require({
header: actionLabel,
message,
icon: 'pi pi-clock',
acceptLabel: 'Confirmar',
rejectLabel: 'Cancelar',
acceptClass: 'p-button-primary',
rejectClass: 'p-button-text',
accept: () => resolve(true),
reject: () => resolve(false),
onHide: () => resolve(false) // fechar via Esc/clickoutside conta como cancelar
});
});
if (!accepted) {
info?.revert?.();
return;
}
await update(id, { inicio_em: startISO, fim_em: endISO });
toast.add({ severity: 'success', summary: actionLabel, detail: 'Alteração salva.', life: 1800 });
} catch (e) {
info?.revert?.();
toast.add({ severity: 'warn', summary: 'Erro', detail: eventsError.value || e?.message || 'Falha ao salvar alteração.', life: 4500 });
}
}
// ── onEditSeriesOccurrence — abre 2º dialog empilhado pra editar
// UMA ocorrência específica da série (preservando o dialog principal
// aberto por baixo). Substituiu o pattern antigo de mutar
// dialogEventRow in-place (que silenciosamente trocava os dados do
// dialog atual e confundia o usuário). Adicionado 2026-05-11.
function onEditSeriesOccurrence({ id, recurrence_date, inicio_em, fim_em, is_virtual }) {
const current = dialogEventRow.value || {};
occDialogEventRow.value = {
...current,
id: id || null,
inicio_em,
fim_em,
recurrence_date,
_is_virtual: is_virtual,
// is_occurrence=true sinaliza pro AgendaEventDialog que essa row
// ja é uma ocorrencia de serie — composer.isEdit retorna true e o
// lifecycle init pula direto pro step 2 (sem mostrar seletor de
// tipo de compromisso). Sem isso, ocorrencia virtual (id=null)
// abria step 1 "escolha o tipo" indevidamente. 2026-05-12.
is_occurrence: true
};
occDialogStartISO.value = inicio_em || '';
occDialogEndISO.value = fim_em || '';
occDialogOpen.value = true;
}
// ── onUpdateSeriesEvent — mudança de status numa ocorrência ──
//
// `row` opcional: row completa quando o chamador NÃO abriu o dialog antes
// (MelissaEventoPanel clica direto no evento → não há dialogEventRow ainda).
// Sem isso, recurrence_id/patient_id caem pra null e criavam row órfã.
// ── Status change (Fase 5, 2026-05-14) ─────────────────────────
// Pra realizado/faltou/cancelado: abre confirm dialog com defaults
// vindos de financial_exceptions + billing_contracts, deixa terapeuta
// override caso a caso. Pros outros status (agendado/confirmado/
// remarcado): aplicação direta (path legacy).
async function onUpdateSeriesEvent(arg) {
const { id, status, recurrence_date, inicio_em, fim_em, is_virtual, row: callerRow, onReject } = arg || {};
try {
const needsDialog = ['realizado', 'faltou', 'cancelado'].includes(status);
if (!needsDialog) {
await _applyStatusUpdateOnly({ id, status, recurrence_date, inicio_em, fim_em, is_virtual, row: callerRow });
return;
}
const row = callerRow || dialogEventRow.value || {};
const ctx = await _loadStatusChangeContext({ row, eventoId: id, status });
// Se nada pra perguntar (ex: status sem regra + sem contrato + sem record pendente), aplica direto
if (!_needsConfirmDialog(status, ctx)) {
await _applyStatusUpdateOnly({ id, status, recurrence_date, inicio_em, fim_em, is_virtual, row: callerRow });
return;
}
// Abre dialog e espera decisão
const decision = await _openStatusDialog({
evento: row,
novoStatus: status,
regraExcecao: ctx.regraExcecao,
billingContract: ctx.billingContract,
billingContractStyle: ctx.billingContract?.charging_style ?? null,
pendingRecord: ctx.pendingRecord,
sessionPrice: Number(row.price ?? 0)
});
if (!decision) {
// User cancelou — reverte status no form do AgendaEventDialog
// (se o trigger veio de lá). Callback opcional.
if (typeof onReject === 'function') onReject();
return;
}
// 1) Materializa ocorrência virtual (ou só update status) — usa o id real
const realId = await _applyStatusUpdateOnly({ id, status, recurrence_date, inicio_em, fim_em, is_virtual, row: callerRow });
// 2) Aplica decisões do dialog
await _applyStatusDecisions({
eventoId: realId,
row,
novoStatus: status,
ctx,
decision
});
} catch (e) {
toast.add({ severity: 'warn', summary: 'Erro', detail: e?.message || 'Falha ao atualizar status.', life: 3000 });
}
}
// Aplica apenas o UPDATE de status (materializando se virtual). Path legacy
// pra status que não precisam de dialog. Retorna o id real do evento
// (pra Fase 5 saber qual record/saldo manipular depois).
async function _applyStatusUpdateOnly({ id, status, recurrence_date, inicio_em, fim_em, is_virtual, row: callerRow }) {
if (id) {
await update(id, { status });
return id;
}
if (!is_virtual || !inicio_em) return null;
const row = callerRow || dialogEventRow.value || {};
const rid = row.recurrence_id ?? row.serie_id ?? dialogEventRow.value?.recurrence_id ?? dialogEventRow.value?.serie_id ?? null;
const rDate = recurrence_date || inicio_em?.slice(0, 10);
if (!rid) {
toast.add({ severity: 'warn', summary: 'Erro', detail: 'Não foi possível identificar a regra de recorrência desta ocorrência.', life: 4000 });
return null;
}
const { data: existing, error: exErr } = await supabase
.from('agenda_eventos')
.select('id')
.eq('recurrence_id', rid)
.eq('recurrence_date', rDate)
.maybeSingle();
if (exErr) throw exErr;
if (existing?.id) {
await update(existing.id, { status });
return existing.id;
} else {
const created = 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,
modalidade: row.modalidade || 'presencial',
price: row.price ?? null
});
return created?.id ?? null;
}
}
// Carrega contexto pra decidir se mostra dialog e quais blocos renderizar.
async function _loadStatusChangeContext({ row, eventoId, status }) {
const ctx = { regraExcecao: null, billingContract: null, pendingRecord: null };
// 1) Regra de exceção (faltou → patient_no_show, cancelado → patient_cancellation)
const exceptionTypeMap = { faltou: 'patient_no_show', cancelado: 'patient_cancellation' };
const excType = exceptionTypeMap[status];
if (excType && clinicTenantId.value) {
try {
const { data } = await supabase
.from('financial_exceptions')
.select('*')
.eq('tenant_id', clinicTenantId.value)
.eq('exception_type', excType)
.or(`owner_id.eq.${ownerId.value},owner_id.is.null`)
.order('owner_id', { ascending: false, nullsLast: true })
.limit(1)
.maybeSingle();
ctx.regraExcecao = data ?? null;
} catch (e) {
console.warn('[Fase5] erro carregando regra de exceção:', e?.message);
}
}
// 2) Billing contract — tenta 3 caminhos:
// (a) row.billing_contract_id direto (sessão real materializada)
// (b) eventoId real → query agenda_eventos.billing_contract_id
// (c) ocorrência virtual (sem id real) → busca contrato ativo do paciente
const patientId = row.patient_id ?? row.paciente_id ?? null;
const contractId = row.billing_contract_id ?? null;
if (contractId) {
try {
const { data } = await supabase.from('billing_contracts').select('*').eq('id', contractId).maybeSingle();
ctx.billingContract = data ?? null;
} catch (e) {
console.warn('[Fase5] erro contract via id direto:', e?.message);
}
}
if (!ctx.billingContract && eventoId) {
// Sessão real materializada — pode ter billing_contract_id no DB mesmo
// que a row passada não tenha (caso de virtual recém-materializada).
try {
const { data: ev } = await supabase.from('agenda_eventos').select('billing_contract_id').eq('id', eventoId).maybeSingle();
if (ev?.billing_contract_id) {
const { data: c } = await supabase.from('billing_contracts').select('*').eq('id', ev.billing_contract_id).maybeSingle();
ctx.billingContract = c ?? null;
}
} catch (e) {
console.warn('[Fase5] erro contract via agenda_evento:', e?.message);
}
}
if (!ctx.billingContract && patientId && clinicTenantId.value) {
// Ocorrência virtual da Anna Freud cai aqui: busca contrato ativo
// do paciente. MVP assume 1 contrato active por paciente; pega o
// mais recente caso haja mais de um.
try {
const { data: c } = await supabase
.from('billing_contracts')
.select('*')
.eq('tenant_id', clinicTenantId.value)
.eq('patient_id', patientId)
.eq('status', 'active')
.eq('type', 'package')
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
ctx.billingContract = c ?? null;
} catch (e) {
console.warn('[Fase5] erro contract via patient_id:', e?.message);
}
}
// 3) Pending record (se evento já existe e tem cobrança pendente)
if (eventoId) {
try {
const { data } = await supabase
.from('financial_records')
.select('*')
.eq('agenda_evento_id', eventoId)
.in('status', ['pending', 'overdue'])
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
ctx.pendingRecord = data ?? null;
} catch (e) {
console.warn('[Fase5] erro pending record:', e?.message);
}
}
return ctx;
}
// Precisa dialog? Sim se há regra de exceção com charge_mode != 'none'
// OU pacote saldo OU pacote upfront OU pending record (realizado).
function _needsConfirmDialog(status, ctx) {
const isFaltouOrCancel = status === 'faltou' || status === 'cancelado';
const isRealizado = status === 'realizado';
const hasRegraComCobranca = ctx.regraExcecao && ctx.regraExcecao.charge_mode !== 'none';
const isPacoteSaldo = ctx.billingContract?.charging_style === 'saldo';
const isPacoteUpfront = ctx.billingContract?.charging_style === 'upfront';
const hasPending = !!ctx.pendingRecord;
if (isFaltouOrCancel) {
// Mostra se há regra ou se é pacote saldo (pra perguntar consume)
return hasRegraComCobranca || isPacoteSaldo || isPacoteUpfront;
}
if (isRealizado) {
// Mostra se há pending (avulsa) ou pacote saldo (cobrança nova)
return hasPending || isPacoteSaldo;
}
return false;
}
// Aplica as decisões do dialog (saldo, multa, paid, cobrança pacote).
async function _applyStatusDecisions({ eventoId, row, novoStatus, ctx, decision }) {
const tenantId = clinicTenantId.value;
const uid = ownerId.value;
const patientId = row.patient_id ?? row.paciente_id ?? null;
const tasks = [];
// 1) Consumir saldo (pacote saldo + faltou/cancelado + decisão sim)
if (decision.consumeSaldo && ctx.billingContract?.id) {
tasks.push(
supabase
.from('billing_contracts')
.update({
sessions_used: (ctx.billingContract.sessions_used ?? 0) + 1,
updated_at: new Date().toISOString()
})
.eq('id', ctx.billingContract.id)
);
}
// 2) Aplicar multa (cria financial_record avulsa)
if (decision.applyFine && decision.fineAmount > 0) {
const dueIso = row.inicio_em ? new Date(row.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
const finePayload = {
owner_id: uid,
tenant_id: tenantId,
patient_id: patientId,
agenda_evento_id: eventoId,
amount: decision.fineAmount,
final_amount: decision.fineAmount,
description: novoStatus === 'faltou' ? 'Multa por falta (no-show)' : 'Taxa de cancelamento',
status: 'pending',
due_date: dueIso,
type: 'receita'
};
tasks.push(
supabase
.from('financial_records')
.insert(finePayload)
.then(({ error }) => {
if (error) {
console.warn('[Fase5] INSERT multa falhou:', error?.message, 'payload:', finePayload);
throw error;
}
})
);
}
// 3) Realizado avulsa pendente: marcar pendingRecord como pago (ou só status)
if (decision.markPaid && ctx.pendingRecord?.id) {
tasks.push(
supabase
.from('financial_records')
.update({
status: 'paid',
paid_at: new Date().toISOString(),
payment_method: decision.paymentMethod || 'pix',
updated_at: new Date().toISOString()
})
.eq('id', ctx.pendingRecord.id)
);
}
// 4) Realizado em pacote saldo: cria cobrança individual + incrementa sessions_used
if (decision.generatePackageCharge && ctx.billingContract?.id) {
const amount = Number(row.price ?? 0);
const dueIso = row.inicio_em ? new Date(row.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
// Cria record
tasks.push(
supabase.rpc('create_financial_record_for_session', {
p_tenant_id: tenantId,
p_owner_id: uid,
p_patient_id: patientId,
p_agenda_evento_id: eventoId,
p_amount: amount,
p_due_date: dueIso
})
);
// Incrementa saldo usado
tasks.push(
supabase
.from('billing_contracts')
.update({
sessions_used: (ctx.billingContract.sessions_used ?? 0) + 1,
updated_at: new Date().toISOString()
})
.eq('id', ctx.billingContract.id)
);
}
// Roda tudo em paralelo (falha parcial é tolerável — toast warn)
const results = await Promise.allSettled(tasks);
const failed = results.filter((r) => r.status === 'rejected');
if (failed.length > 0) {
const firstErr = failed[0].reason?.message || 'sem detalhe';
toast.add({ severity: 'error', summary: 'Erro ao aplicar decisões', detail: `${failed.length} ação(ões) falharam: ${firstErr}`, life: 7000 });
console.error('[Fase5] falhas em _applyStatusDecisions:', failed.map((f) => f.reason));
} else if (tasks.length > 0) {
toast.add({ severity: 'success', summary: 'Status atualizado', detail: 'Decisões aplicadas.', life: 2500 });
}
// Pós: se gerou cobrança via link Asaas, marcar payment_method='asaas'
if (decision.generatePackageCharge && decision.paymentMethod === 'link' && eventoId) {
try {
const { data: newRec } = await supabase
.from('financial_records')
.select('id')
.eq('agenda_evento_id', eventoId)
.order('created_at', { ascending: false })
.limit(1)
.single();
if (newRec?.id) {
await supabase.from('financial_records').update({ payment_method: 'asaas', updated_at: new Date().toISOString() }).eq('id', newRec.id);
}
} catch { /* silencioso */ }
} else if (decision.generatePackageCharge && decision.paymentMethod !== 'link' && eventoId) {
// Já recebi → marca como paid
try {
const { data: newRec } = await supabase
.from('financial_records')
.select('id')
.eq('agenda_evento_id', eventoId)
.order('created_at', { ascending: false })
.limit(1)
.single();
if (newRec?.id) {
await supabase
.from('financial_records')
.update({
status: 'paid',
paid_at: new Date().toISOString(),
payment_method: decision.paymentMethod,
updated_at: new Date().toISOString()
})
.eq('id', newRec.id);
}
} catch { /* silencioso */ }
}
}
// ── onDialogSave / onDialogDelete ── populados na Stage 2 ──
const onDialogSave = _buildOnDialogSave(deps);
const onDialogDelete = _buildOnDialogDelete(deps);
return {
onEditEvento,
onCreateEvento,
onCreateEventoForPatient,
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,
ownerId, clinicTenantId, settings,
eventsError,
create, update,
createRule, updateRule, splitRuleAt, upsertException,
saveRuleItems, propagateToSerie,
dialogOpen, dialogEventRow,
occDialogOpen, serieRefreshTick,
_reloadRange
} = deps;
return async function onDialogSave(arg) {
let normalized = null;
// _occurrenceMode: payload veio do 2º dialog empilhado (editar UMA
// ocorrencia da serie). Nesse caso, fechamos o dialog da OCORRENCIA
// (nao o pai) e bumpamos serieRefreshTick pra que o pai re-rode
// loadSerieEvents e a pill reflita o novo status. Sem isso, o pai
// fechava silenciosamente e a pill ficava com status stale. 2026-05-12.
const isOccurrence = !!arg?._occurrenceMode;
function closeDialog() {
if (isOccurrence) {
occDialogOpen.value = false;
serieRefreshTick.value++;
} else {
dialogOpen.value = false;
}
}
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);
}
// Opção C1 (2026-05-13): cobrança decidida ANTES de salvar via
// chargeMode no payload. Substituiu o confirm.require pós-save.
// 'package' → 1 billing_contract com valor total da série
// 'per_session' → materializa N agenda_eventos + N financial_records
// 'none' → nada
const recChargeMode = arg?.chargeMode || 'none';
let chargeInfo = null;
if (createdRule?.id && recorrencia.commitmentItems?.length && recorrencia.qtdSessoes && normalized.paciente_id) {
if (recChargeMode === 'package') {
chargeInfo = await _createPackageContract({
rule: createdRule,
normalized,
recorrencia,
tenantId: clinicId,
packageStyle: arg?.packageStyle || 'upfront',
paymentMethod: arg?.paymentMethod || 'link',
markPaidNow: arg?.markPaidNow === true
});
} else if (recChargeMode === 'per_session') {
chargeInfo = await _materializeAndChargePerSession({ rule: createdRule, normalized, recorrencia, tenantId: clinicId });
}
}
// chargeMode='none' (C6 doc): materializa a 1ª ocorrência
// sem criar financial_record. As demais ficam virtuais.
// Honra: "1ª materializada com badge $ a cobrar; outras 3
// virtuais expandidas em runtime, limpas até interação".
// package/per_session ja materializam dentro dos proprios
// helpers; package 'saldo' intencionalmente NÃO materializa
// (doc C8 — modelo Cliniko, todas virtuais ate interacao).
// IMPORTANTE: agendaRepository.createAgendaEvento dropa
// 'paciente_id' (campo legado). USAR 'patient_id' (English),
// que é o nome real da coluna em agenda_eventos.
const _patientIdForFirst = normalized.patient_id ?? normalized.paciente_id ?? null;
if (createdRule?.id && recChargeMode === 'none' && _patientIdForFirst) {
try {
const durMin = createdRule.duration_min || 50;
const [hh, mm] = String(createdRule.start_time || '00:00:00').slice(0, 5).split(':').map(Number);
const firstISO = createdRule.start_date;
const startDt = new Date(`${firstISO}T00:00:00`);
startDt.setHours(hh, mm, 0, 0);
const endDt = new Date(startDt.getTime() + durMin * 60 * 1000);
await create({
owner_id: createdRule.owner_id,
tenant_id: clinicId,
terapeuta_id: createdRule.therapist_id ?? null,
recurrence_id: createdRule.id,
recurrence_date: firstISO,
tipo: normalized.tipo || 'sessao',
status: normalized.status || 'agendado',
titulo: normalized.titulo || 'Sessão',
inicio_em: startDt.toISOString(),
fim_em: endDt.toISOString(),
patient_id: _patientIdForFirst,
determined_commitment_id: normalized.determined_commitment_id ?? null,
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,
visibility_scope: normalized.visibility_scope || 'public'
});
} catch (e) {
console.warn('[useMelissaAgenda] materializa 1ª (chargeMode=none) falhou:', e?.message);
}
}
const detail = recorrencia.qtdSessoes ? `${recorrencia.qtdSessoes} sessões criadas` : 'Série recorrente criada';
toast.add({ severity: 'success', summary: 'Série criada', detail, life: 3000 });
if (chargeInfo?.toast) toast.add(chargeInfo.toast);
closeDialog();
await _reloadRange();
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 });
closeDialog();
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 });
closeDialog();
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 });
closeDialog();
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 });
closeDialog();
await _reloadRange();
return;
}
// ── CASO A/B: evento avulso ou sessão única ──────────────────────
const dbPayload = pickDbFields(normalized);
let createdEventId = null;
if (id) {
await update(id, dbPayload);
await arg.onSaved?.(id);
createdEventId = id;
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Compromisso atualizado.', life: 2500 });
} else {
const created = await create(dbPayload);
await arg.onSaved?.(created.id);
createdEventId = created.id;
toast.add({ severity: 'success', summary: 'Criado', detail: 'Compromisso criado.', life: 2500 });
}
// Opção C1 (2026-05-13): cobrança avulsa via chargeMode='session'.
// Cria 1 financial_record vinculado ao evento. Falha aqui não
// reverte a sessão — toast warn pra gerar manualmente depois.
// Refatorado 2026-05-16: paymentSettlement substituído por
// paymentMethod + markPaidNow. Handler aplica payment_method sempre;
// status='paid' apenas quando markPaidNow && method !== 'link'.
if (arg?.chargeMode === 'session' && createdEventId) {
try {
// Convênio: valor vem em insurance_value (não price);
// payment_method fixo em 'convenio' (não asaas/pix/etc) —
// o plano paga a clínica no fechamento mensal, nunca via
// gateway ou no caixa. markPaidNow ignorado pra convênio
// (record sempre nasce pending até a baixa via Financeiro).
const isConvenio = !!normalized.insurance_plan_id;
const amount = isConvenio
? (normalized.insurance_value ?? 0)
: (normalized.price ?? 0);
const dueDate = normalized.inicio_em
? new Date(normalized.inicio_em).toISOString().slice(0, 10)
: new Date().toISOString().slice(0, 10);
const { error: cobErr } = await supabase.rpc('create_financial_record_for_session', {
p_tenant_id: clinicId,
p_owner_id: normalized.owner_id,
p_patient_id: normalized.paciente_id ?? null,
p_agenda_evento_id: createdEventId,
p_amount: amount,
p_due_date: dueDate
});
if (cobErr) throw cobErr;
// Pós-RPC: ajusta payment_method (sempre) e status (só se
// markPaidNow=true e método direto). Convênio força method
// = 'convenio' e ignora markPaidNow.
// convenio → payment_method='convenio', status=pending
// method='link' → payment_method='asaas', status=pending
// method=pix/etc + markPaidNow=false → payment_method=<>, status=pending
// method=pix/etc + markPaidNow=true → payment_method=<>, status='paid', paid_at=now()
const method = isConvenio
? 'convenio'
: (arg?.paymentMethod || 'link');
const paidNow = !isConvenio && arg?.markPaidNow === true && method !== 'link';
try {
const { data: recRow } = await supabase
.from('financial_records')
.select('id')
.eq('agenda_evento_id', createdEventId)
.order('created_at', { ascending: false })
.limit(1)
.single();
if (recRow?.id) {
const patch = {
updated_at: new Date().toISOString(),
payment_method: method === 'link' ? 'asaas' : method
};
if (paidNow) {
patch.status = 'paid';
patch.paid_at = new Date().toISOString();
}
const { error: upErr } = await supabase.from('financial_records').update(patch).eq('id', recRow.id);
if (upErr) throw upErr;
}
} catch (e2) {
console.warn('[useMelissaAgenda] pos-cobranca update falhou:', e2?.message);
}
const methodLabel = {
pix: 'PIX',
dinheiro: 'dinheiro',
deposito: 'depósito',
cartao_maquininha: 'cartão (maquininha)',
convenio: 'convênio'
}[method] || null;
toast.add({
severity: 'success',
summary: paidNow ? 'Cobrança paga' : 'Cobrança gerada',
detail: paidNow
? `R$ ${Number(amount).toFixed(2).replace('.', ',')} recebido via ${methodLabel}.`
: `R$ ${Number(amount).toFixed(2).replace('.', ',')} com vencimento em ${dueDate.split('-').reverse().join('/')}${methodLabel ? ` (${methodLabel})` : ''}.`,
life: 3500
});
} catch (e) {
toast.add({
severity: 'warn',
summary: 'Sessão salva, mas cobrança falhou',
detail: e?.message || 'Você pode gerar manualmente pelo Financeiro.',
life: 5000
});
}
}
closeDialog();
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,
occDialogOpen, serieRefreshTick,
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);
// Mesma logica do save: delete acionado do 2º dialog empilhado fecha
// a ocorrencia + bumpa pill refresh; do pai fecha o pai. 2026-05-12.
const isOccurrence = typeof arg === 'object' && !!arg?._occurrenceMode;
function closeDialog() {
if (isOccurrence) {
occDialogOpen.value = false;
serieRefreshTick.value++;
} else {
dialogOpen.value = false;
}
}
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 });
closeDialog();
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 });
closeDialog();
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 });
closeDialog();
await _reloadRange();
return;
}
// fallback
if (id) await remove(id);
toast.add({ severity: 'success', summary: 'Excluído', detail: 'Compromisso removido.', life: 2500 });
closeDialog();
await _reloadRange();
} catch (e) {
toast.add({ severity: 'warn', summary: 'Erro', detail: eventsError.value || e?.message || 'Falha ao excluir.', life: 4500 });
}
};
}
// ───────────────────────────────────────────────────────────────────────────
// Helpers de cobrança em série (Opção C1, 2026-05-13)
// ───────────────────────────────────────────────────────────────────────────
const _BRL = (v) => Number(v ?? 0).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
// Calcula valor total da série a partir dos commitmentItems.
function _computeSeriePrice(recorrencia) {
const items = recorrencia.commitmentItems || [];
const n = recorrencia.qtdSessoes;
const totalPorSessao = items.reduce((s, i) => s + (i.final_price ?? 0), 0);
const pacoteFechado = recorrencia.serieValorMode === 'dividir';
return {
n,
perSessao: pacoteFechado ? totalPorSessao / n : totalPorSessao,
packagePrice: pacoteFechado ? totalPorSessao : totalPorSessao * n
};
}
// chargeMode='package' — 2 estilos (2026-05-14):
// - 'upfront' (default): cria billing_contract + materializa 1ª ocorrência
// em agenda_eventos + cria 1 financial_record com valor TOTAL do pacote
// (vencimento na data da 1ª sessão). Demais ocorrências continuam virtuais.
// Suporta paymentMethod + markPaidNow — marca record como pago quando true.
// - 'saldo': só cria billing_contract (Cliniko style). Sem financial_record
// imediato — cobranças individuais nascem conforme sessões.
async function _createPackageContract({ rule, normalized, recorrencia, tenantId, packageStyle = 'upfront', paymentMethod = 'link', markPaidNow = false }) {
const { n, packagePrice } = _computeSeriePrice(recorrencia);
try {
// 1) billing_contract — referência do pacote em ambos os estilos.
// charging_style: identifica como o pacote foi cobrado na criação;
// handler de status change usa pra decidir entre "só status" (upfront)
// ou "criar cobrança + consumir saldo" (saldo).
const { data: createdContract, error: contractErr } = 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',
charging_style: packageStyle === 'saldo' ? 'saldo' : 'upfront'
})
.select('id')
.single();
if (contractErr) throw contractErr;
const contractId = createdContract?.id ?? null;
// Estilo 'saldo': para aqui — sem cobrança imediata.
if (packageStyle === 'saldo') {
return {
toast: {
severity: 'success',
summary: 'Pacote criado (saldo)',
detail: `${n} sessões — total ${_BRL(packagePrice)}. Cobranças individuais conforme sessões.`,
life: 3500
}
};
}
// Estilo 'upfront': materializa 1ª ocorrência + 1 financial_record total.
const durMin = rule.duration_min || 50;
const [hh, mm] = String(rule.start_time || '00:00:00').slice(0, 5).split(':').map(Number);
const firstISO = rule.start_date;
const startDt = new Date(`${firstISO}T00:00:00`);
startDt.setHours(hh, mm, 0, 0);
const endDt = new Date(startDt.getTime() + durMin * 60 * 1000);
const { data: createdEvent, error: evErr } = await supabase
.from('agenda_eventos')
.insert({
owner_id: rule.owner_id,
tenant_id: tenantId,
terapeuta_id: rule.therapist_id ?? null,
recurrence_id: rule.id,
recurrence_date: firstISO,
tipo: 'sessao',
status: 'agendado',
titulo: normalized.titulo || 'Sessão',
inicio_em: startDt.toISOString(),
fim_em: endDt.toISOString(),
patient_id: normalized.paciente_id,
determined_commitment_id: normalized.determined_commitment_id ?? null,
modalidade: rule.modalidade || normalized.modalidade || 'presencial',
price: packagePrice,
billing_contract_id: contractId,
visibility_scope: normalized.visibility_scope || 'public'
})
.select('id')
.single();
if (evErr) throw evErr;
// 2) financial_record do pacote total via RPC.
const { error: cobErr } = await supabase.rpc('create_financial_record_for_session', {
p_tenant_id: tenantId,
p_owner_id: rule.owner_id,
p_patient_id: normalized.paciente_id ?? null,
p_agenda_evento_id: createdEvent.id,
p_amount: packagePrice,
p_due_date: firstISO
});
if (cobErr) throw cobErr;
// 3) Pós-RPC: ajusta payment_method (sempre) e status (só se markPaidNow=true).
// method='link' → payment_method='asaas', status pending
// method=pix/etc + markPaidNow=false → payment_method=<>, status pending
// method=pix/etc + markPaidNow=true → payment_method=<>, status='paid', paid_at=now()
const paidNow = markPaidNow === true && paymentMethod !== 'link';
const { data: recRow } = await supabase
.from('financial_records')
.select('id')
.eq('agenda_evento_id', createdEvent.id)
.order('created_at', { ascending: false })
.limit(1)
.single();
if (recRow?.id) {
const patch = {
updated_at: new Date().toISOString(),
payment_method: paymentMethod === 'link' ? 'asaas' : paymentMethod
};
if (paidNow) {
patch.status = 'paid';
patch.paid_at = new Date().toISOString();
}
await supabase.from('financial_records').update(patch).eq('id', recRow.id);
}
const methodLabel = {
pix: 'PIX',
dinheiro: 'dinheiro',
deposito: 'depósito',
cartao_maquininha: 'cartão (maquininha)'
}[paymentMethod] || null;
return {
toast: {
severity: 'success',
summary: paidNow ? 'Pacote pago' : 'Pacote criado',
detail: paidNow
? `${n} sessões — ${_BRL(packagePrice)} recebido via ${methodLabel}.`
: `${n} sessões — total ${_BRL(packagePrice)} com vencimento em ${firstISO.split('-').reverse().join('/')}.`,
life: 4000
}
};
} catch (e) {
return {
toast: {
severity: 'warn',
summary: 'Pacote não gerado',
detail: e?.message || 'Falha ao criar contrato. Você pode gerar manualmente pelo Financeiro.',
life: 5000
}
};
}
}
// chargeMode='per_session': materializa todas as N ocorrências como
// agenda_eventos reais + cria 1 financial_record por ocorrência.
// Respeita recurrence_exceptions (feriado_block / cancel_session) — não
// materializa nessas datas. Falha parcial é tolerada (toast warn).
async function _materializeAndChargePerSession({ rule, normalized, recorrencia, tenantId }) {
const { n, perSessao } = _computeSeriePrice(recorrencia);
try {
// 1) Gerar a lista de datas das N ocorrências respeitando exceptions.
const exceptionDates = new Set((recorrencia.conflitos || []).map((c) => c.date));
const dates = _generateOccurrenceDates(rule, n + exceptionDates.size, exceptionDates).slice(0, n);
// 2) Montar rows pra inserção em agenda_eventos.
const durMin = rule.duration_min || 50;
const [hh, mm] = String(rule.start_time || '00:00:00').slice(0, 5).split(':').map(Number);
const rows = dates.map((iso) => {
const startDt = new Date(`${iso}T00:00:00`);
startDt.setHours(hh, mm, 0, 0);
const endDt = new Date(startDt.getTime() + durMin * 60 * 1000);
return {
owner_id: rule.owner_id,
tenant_id: tenantId,
terapeuta_id: rule.therapist_id ?? null,
recurrence_id: rule.id,
recurrence_date: iso,
tipo: 'sessao',
status: 'agendado',
titulo: normalized.titulo || 'Sessão',
inicio_em: startDt.toISOString(),
fim_em: endDt.toISOString(),
patient_id: normalized.paciente_id,
determined_commitment_id: normalized.determined_commitment_id ?? null,
modalidade: rule.modalidade || normalized.modalidade || 'presencial',
price: perSessao,
visibility_scope: normalized.visibility_scope || 'public'
};
});
// 3) Insert batch dos eventos.
const { data: createdEvents, error: evErr } = await supabase.from('agenda_eventos').insert(rows).select('id, inicio_em');
if (evErr) throw evErr;
// 4) Pra cada evento criado, criar financial_record via RPC. Loop
// sequencial pra simplificar — N=4 é pouco; se virar gargalo, batchify.
let okCount = 0;
let failCount = 0;
for (const ev of createdEvents || []) {
try {
const dueDate = ev.inicio_em ? new Date(ev.inicio_em).toISOString().slice(0, 10) : new Date().toISOString().slice(0, 10);
const { error: cobErr } = await supabase.rpc('create_financial_record_for_session', {
p_tenant_id: tenantId,
p_owner_id: rule.owner_id,
p_patient_id: normalized.paciente_id ?? null,
p_agenda_evento_id: ev.id,
p_amount: perSessao,
p_due_date: dueDate
});
if (cobErr) throw cobErr;
okCount++;
} catch {
failCount++;
}
}
if (failCount === 0) {
return {
toast: {
severity: 'success',
summary: `${okCount} cobranças geradas`,
detail: `${_BRL(perSessao)} por sessão. Total: ${_BRL(perSessao * okCount)}.`,
life: 4000
}
};
}
return {
toast: {
severity: 'warn',
summary: 'Cobranças parcialmente geradas',
detail: `${okCount} ok, ${failCount} falharam. Gere as faltantes manualmente pelo Financeiro.`,
life: 6000
}
};
} catch (e) {
return {
toast: {
severity: 'warn',
summary: 'Falha ao materializar série',
detail: e?.message || 'Sessões podem ter sido criadas em parte. Confira no Financeiro.',
life: 6000
}
};
}
}
// Gera lista de datas ISO ('YYYY-MM-DD') a partir da regra. Pula datas
// em exceptionDates (Set). Para até `max` datas. Suporta weekly (interval=1
// ou 2 pra quinzenal) e custom_weekdays.
function _generateOccurrenceDates(rule, max, exceptionDates) {
const dates = [];
const start = new Date(`${rule.start_date}T00:00:00`);
const interval = Math.max(1, rule.interval || 1);
const weekdays = Array.isArray(rule.weekdays) && rule.weekdays.length ? rule.weekdays.map(Number) : [start.getDay()];
const isCustom = rule.type === 'custom_weekdays';
const cursor = new Date(start);
let safety = 0;
// Para weekly (interval=1), avança 7 dias por iteração. Quinzenal: 14.
// Para custom_weekdays, avança 1 dia e filtra weekdays.includes.
while (dates.length < max && safety < 365 * 3) {
const iso = _dateToISO(cursor);
const dow = cursor.getDay();
const inWeekdays = weekdays.includes(dow);
if (inWeekdays && !exceptionDates.has(iso)) {
dates.push(iso);
}
if (isCustom) {
cursor.setDate(cursor.getDate() + 1);
} else if (inWeekdays) {
// weekly/quinzenal: ao bater o dow, pula interval semanas
cursor.setDate(cursor.getDate() + 7 * interval);
} else {
cursor.setDate(cursor.getDate() + 1);
}
safety++;
}
return dates;
}
function _dateToISO(d) {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}