Files
agenciapsilmno/src/layout/melissa/composables/useMelissaAgenda.js
T
Leonardo fad1f4ebd4 agenda: C8 OK + Usar/Revogar pacote saldo + UI de contract + ajustes UX
Cenário 8 (Pacote SALDO — Otávio Souza Ferreira 12 × R$ 50)
- Testado e passou. DB: 1 rule, 0 events, 1 contract (saldo), 0 records.
  Visual: 12 virtuais limpas no calendário.

UI de pacote (saldo + upfront)
- _ruleContractMap em useMelissaAgenda: bulk-load popula contract info
  (id, style, totalSessions, sessionsUsed, packagePrice) por
  recurrence_id. Query recurrence_rules.patient_id como fonte
  autoritativa — cobre saldo sem materializadas (sem isso, ruleToPatient
  via records vinha vazio pra saldo)
- normalize injeta `contract` no evento via ruleContractMap
- MelissaEventoPanel: nova linha colorida (violeta saldo, verde upfront)
  com "Pacote saldo · N/M usadas" ou "Pacote · N/M realizadas"
- AgendaEventDialog: info card mt-4 com header+body+hint explicando
  modelo, gateado por occFinancialLoading (spinner durante carga
  pra evitar piscar entre Usar/Revogar)

Handlers Usar/Revogar atômicos
- onUsarSessao em MelissaLayout: materializa virtual (preserva
  determined_commitment_id da regra) → status=realizado +
  billing_contract_id → create_financial_record_for_session →
  sessions_used++ → (se atingiu total) contract.status=completed
- onRevogarSessao: cancela record + sessions_used-- + reativa contract
  se estava completed + status=agendado. Bloqueia se record paid
  (precisa estorno formal pelo Financeiro)
- Ambos aceitam payload {eventRow, contract} do dialog OU fallback
  pra eventoSelecionado do popover
- Botão "Usar" verde no popover (paymentState=none) substituído por
  "Revogar" vermelho (paymentState=pending). Equivalente "Usar agora"/
  "Revogar uso" no info card do dialog

Fix enum status_evento_agenda
- 'realizada' não existe no enum — DB exige 'realizado' (masculino).
  Corrigido em todas as ocorrências do handler

Fix campo "Título" indevido em sessão
- Sessão sem determined_commitment_id → selectedCommitment=null →
  isSessionEvent=false → mostra campo Título (que é só pra não-sessão)
- Fix: materialize do Usar inclui determined_commitment_id (insert
  path); update path backfilla via query da rule se NULL; Revogar
  também backfilla pra consistência

Fix "Gerar fatura" não cabe em saldo
- Botão "Gerar fatura" do popover hide quando há contractInfo. Em
  saldo, gerar fatura solta criaria cobrança duplicada sem incrementar
  sessions_used. Fluxo correto: "Usar"

Recorrências Aplicadas — UI
- Header stats coloridos: total **azul**, realizadas **verde**,
  faltaram **amber**, canceladas **cinza**, remarcadas **violeta**
- Pills com badge sólido por status (emerald-600 realizado, amber-600
  faltou, stone-500 cancelado, violet-600 remarcado)

Race condition no dialog
- AgendaEventDialog mostrava botões Usar/Revogar baseado em
  occFinancialRecord async; durante ~500ms de load, botão errado
  podia piscar. Fix: spinner "Verificando estado…" enquanto
  occFinancialLoading=true; botões só renderizam após
- Popover não fixado (race window pequena, fechar/reabrir resolve)

3 decisões UX confirmadas antes de codar
- Editar serviço pago → NÃO (cobrança fiscal imutável)
- Alternar Particular/Convênio/Gratuito em série cobrada → NÃO
- Gerar fatura individual em pacote upfront → NÃO (duplicação)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 23:27:20 -03:00

2585 lines
126 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* useMelissaAgenda — Orquestrador da Agenda dentro do Layout Melissa.
* --------------------------------------------------
* Equivalente Melissa-exclusivo de AgendaTerapeutaPage.vue: carrega settings,
* eventos reais (useAgendaEvents), expande recorrências (useRecurrence),
* resolve compromissos determinados (catálogo) e feriados, mantém o estado
* do dialog de edição, e expõe TODOS os handlers que o dialog/FC precisam:
* onDialogSave / onDialogDelete — CRUD com 7 cases (avulso, recorrente,
* somente_este, este_e_seguintes, todos,
* todos_sem_excecao)
* onUpdateSeriesEvent — mudança de status numa ocorrência
* onEditSeriesOccurrence — preview ao alternar editScope
* persistMoveOrResize — drag/resize do FC com checagem de conflito
* onSelectTime — click-drag pra criar evento novo
* onEditEvento — popula dialogEventRow a partir do raw row
*
* Eventos retornados em `eventos` são Melissa-shape (color, label, startH/endH,
* dateKey, _raw) — `_raw` carrega o registro bruto + flags de ocorrência virtual
* pra alimentar o AgendaEventDialog.
*
* Cria refs `viewStart`/`viewEnd` internamente — MelissaAgenda escreve neles
* no datesSet do FC (via provide/inject). O composable observa e refetcha.
*
* Os handlers exibem toasts (success/warn) — o composable assume que os
* componentes consumidores já registraram `<Toast />` e `<ConfirmDialog />`.
*/
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { supabase } from '@/lib/supabase/client';
import { useTenantStore } from '@/stores/tenantStore';
import { useAgendaSettings } from '@/features/agenda/composables/useAgendaSettings';
import { useAgendaEvents } from '@/features/agenda/composables/useAgendaEvents';
import { useRecurrence } from '@/features/agenda/composables/useRecurrence';
import { useDeterminedCommitments } from '@/features/agenda/composables/useDeterminedCommitments';
import { useCommitmentServices } from '@/features/agenda/composables/useCommitmentServices';
import { useFeriados } from '@/composables/useFeriados';
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 10240; convencionamos 120 (2h) aqui pra
// evitar slots gigantes acidentais. Futuro: ler de `agenda_configuracoes` se
// `max_session_duration_min` for adicionado.
const MAX_SESSION_MINUTES = 120;
function isUuid(v) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(v || ''));
}
function normalizeEventoTipo(t, fallback = EVENTO_TIPO.SESSAO) {
const s = String(t || '').trim().toLowerCase();
if (!s) return fallback;
if (s.includes('sess')) return EVENTO_TIPO.SESSAO;
if (s.includes('bloq') || s.includes('ocup')) return EVENTO_TIPO.BLOQUEIO;
return EVENTO_TIPOS_VALIDOS.has(s) ? s : fallback;
}
function deriveEventoTipoForNewEvent(payload) {
const vis = String(payload?.visibility_scope || '').toLowerCase();
const title = String(payload?.titulo || '').toLowerCase();
if (vis === 'busy_only' || title.includes('ocup')) return EVENTO_TIPO.BLOQUEIO;
return EVENTO_TIPO.SESSAO;
}
function deriveTituloDefaultByTipo(tipo) {
return normalizeEventoTipo(tipo, EVENTO_TIPO.SESSAO) === EVENTO_TIPO.BLOQUEIO ? 'Ocupado' : 'Sessão';
}
function pickDbFields(obj) {
const allowed = [
'tenant_id', 'owner_id', 'terapeuta_id', 'patient_id',
'tipo', 'status', 'titulo', 'observacoes', '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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ── onEditEvento — chamado pelo MelissaEventoPanel ao clicar "Editar" ──
// Recebe o `_raw` normalizado e popula o dialogEventRow no shape esperado
// pelo AgendaEventDialog (mesma estrutura de AgendaTerapeutaPage.onEventClick).
function onEditEvento(rawRow) {
if (!rawRow) return;
dialogEventRow.value = {
id: rawRow.is_occurrence ? null : rawRow.id || null,
owner_id: rawRow.owner_id ?? ownerId.value,
terapeuta_id: rawRow.terapeuta_id ?? null,
paciente_id: rawRow.patient_id ?? rawRow.paciente_id ?? null,
paciente_nome: rawRow.paciente_nome ?? rawRow.patient_name ?? rawRow.patients?.nome_completo ?? null,
paciente_avatar: rawRow.paciente_avatar ?? rawRow.patients?.avatar_url ?? null,
paciente_status: rawRow.paciente_status ?? rawRow.patients?.status ?? null,
tipo: rawRow.tipo || 'sessao',
status: rawRow.status,
titulo: rawRow.titulo,
observacoes: rawRow.observacoes ?? null,
visibility_scope: rawRow.visibility_scope ?? 'public',
inicio_em: rawRow.inicio_em,
fim_em: rawRow.fim_em,
modalidade: rawRow.modalidade ?? null,
determined_commitment_id: rawRow.determined_commitment_id ?? null,
titulo_custom: rawRow.titulo_custom ?? null,
extra_fields: rawRow.extra_fields ?? null,
price: rawRow.price != null ? Number(rawRow.price) : null,
insurance_plan_id: rawRow.insurance_plan_id ?? null,
insurance_guide_number: rawRow.insurance_guide_number ?? null,
insurance_value: rawRow.insurance_value != null ? Number(rawRow.insurance_value) : null,
insurance_plan_service_id: rawRow.insurance_plan_service_id ?? null,
// recorrência
recurrence_id: rawRow.recurrence_id ?? null,
original_date: rawRow.original_date ?? rawRow.recurrence_date ?? null,
is_occurrence: !!rawRow.is_occurrence,
exception_type: rawRow.exception_type ?? rawRow.exceptionType ?? null,
// legado
serie_id: rawRow.serie_id ?? rawRow.recurrence_id ?? null,
serie_dia_semana: rawRow.serie_dia_semana ?? null,
serie_hora: rawRow.serie_hora ?? null
};
dialogStartISO.value = '';
dialogEndISO.value = '';
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 <strong>. Subject
// é escapado pra evitar XSS via nome de paciente.
const oldStart = info.oldEvent?.start || null;
const oldEnd = info.oldEvent?.end || null;
const ext = ev.extendedProps || {};
const subject = ext.pacienteNome || ext.label || ext.titulo || 'evento';
const subjectEsc = _esc(subject);
const startMoved = oldStart && start.getTime() !== oldStart.getTime();
const endMoved = oldEnd && end.getTime() !== oldEnd.getTime();
let message;
if (startMoved) {
const sameDay = oldStart.toDateString() === start.toDateString();
if (sameDay) {
// Mudou só hora (mesmo dia)
message =
`Está trocando o horário de ${subjectEsc} de ` +
`<strong>${_fmtH(oldStart)}</strong> para ` +
`<strong>${_fmtH(start)}</strong>. Confirma?`;
} else {
// Mudou de dia (com ou sem mudança de hora) — destaca
// data E hora em ambos os lados
message =
`Está movendo ${subjectEsc} de ` +
`<strong>${_fmtD(oldStart)} ${_fmtH(oldStart)}</strong> para ` +
`<strong>${_fmtD(start)} ${_fmtH(start)}</strong>. Confirma?`;
}
} else if (endMoved) {
// Só resize — mostra duração antiga → nova
const oldDur = Math.round((oldEnd.getTime() - oldStart.getTime()) / 60000);
const newDur = Math.round((end.getTime() - start.getTime()) / 60000);
message =
`Está alterando a duração de ${subjectEsc} de ` +
`<strong>${oldDur}min</strong> para ` +
`<strong>${newDur}min</strong>. Confirma?`;
} else {
message = `Confirmar alteração de ${subjectEsc}?`;
}
const accepted = await new Promise((resolve) => {
confirm.require({
header: actionLabel,
message,
icon: 'pi pi-clock',
acceptLabel: 'Confirmar',
rejectLabel: 'Cancelar',
acceptClass: 'p-button-primary',
rejectClass: 'p-button-text',
accept: () => resolve(true),
reject: () => resolve(false),
onHide: () => resolve(false) // fechar via Esc/clickoutside conta como cancelar
});
});
if (!accepted) {
info?.revert?.();
return;
}
await update(id, { inicio_em: startISO, fim_em: endISO });
toast.add({ severity: 'success', summary: actionLabel, detail: 'Alteração salva.', life: 1800 });
} catch (e) {
info?.revert?.();
toast.add({ severity: 'warn', summary: 'Erro', detail: eventsError.value || e?.message || 'Falha ao salvar alteração.', life: 4500 });
}
}
// ── onEditSeriesOccurrence — 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}`;
}