fad1f4ebd4
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>
2585 lines
126 KiB
JavaScript
2585 lines
126 KiB
JavaScript
/*
|
||
* 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 10–240; convencionamos 120 (2h) aqui pra
|
||
// evitar slots gigantes acidentais. Futuro: ler de `agenda_configuracoes` se
|
||
// `max_session_duration_min` for adicionado.
|
||
const MAX_SESSION_MINUTES = 120;
|
||
|
||
function isUuid(v) {
|
||
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(String(v || ''));
|
||
}
|
||
|
||
function normalizeEventoTipo(t, fallback = EVENTO_TIPO.SESSAO) {
|
||
const s = String(t || '').trim().toLowerCase();
|
||
if (!s) return fallback;
|
||
if (s.includes('sess')) return EVENTO_TIPO.SESSAO;
|
||
if (s.includes('bloq') || s.includes('ocup')) return EVENTO_TIPO.BLOQUEIO;
|
||
return EVENTO_TIPOS_VALIDOS.has(s) ? s : fallback;
|
||
}
|
||
|
||
function deriveEventoTipoForNewEvent(payload) {
|
||
const vis = String(payload?.visibility_scope || '').toLowerCase();
|
||
const title = String(payload?.titulo || '').toLowerCase();
|
||
if (vis === 'busy_only' || title.includes('ocup')) return EVENTO_TIPO.BLOQUEIO;
|
||
return EVENTO_TIPO.SESSAO;
|
||
}
|
||
|
||
function deriveTituloDefaultByTipo(tipo) {
|
||
return normalizeEventoTipo(tipo, EVENTO_TIPO.SESSAO) === EVENTO_TIPO.BLOQUEIO ? 'Ocupado' : 'Sessão';
|
||
}
|
||
|
||
function pickDbFields(obj) {
|
||
const allowed = [
|
||
'tenant_id', 'owner_id', 'terapeuta_id', 'patient_id',
|
||
'tipo', 'status', 'titulo', 'observacoes', 'modalidade',
|
||
'inicio_em', 'fim_em', 'visibility_scope',
|
||
'mirror_of_event_id', 'mirror_source',
|
||
'determined_commitment_id', 'titulo_custom', 'extra_fields',
|
||
'recurrence_id', 'recurrence_date',
|
||
'price', 'insurance_plan_id', 'insurance_guide_number',
|
||
'insurance_value', 'insurance_plan_service_id'
|
||
];
|
||
const out = {};
|
||
for (const k of allowed) {
|
||
if (obj[k] !== undefined) out[k] = obj[k];
|
||
}
|
||
return out;
|
||
}
|
||
|
||
function _addMinutesToTime(timeStr, minutes) {
|
||
const [h, m] = String(timeStr || '09:00').split(':').map(Number);
|
||
const total = h * 60 + m + Number(minutes || 0);
|
||
const hh = Math.floor(total / 60);
|
||
const mm = total % 60;
|
||
return `${String(hh).padStart(2, '0')}:${String(mm).padStart(2, '0')}:00`;
|
||
}
|
||
|
||
// ─── Melissa-style normalize (color, label, startH/endH, dateKey, _raw) ─────
|
||
function pickColor(tipo, status, isOccurrence) {
|
||
const s = String(status || '').toLowerCase();
|
||
if (s === 'realizado' || s === 'realizada') return '#10b981';
|
||
if (s === 'faltou') return '#ef4444';
|
||
if (s === 'cancelado' || s === 'cancelada') return '#94a3b8';
|
||
|
||
const t = String(tipo || '').toLowerCase();
|
||
if (t === 'bloqueio') return '#64748b';
|
||
if (t === 'supervisao' || t === 'supervisão') return '#a855f7';
|
||
if (t === 'reuniao' || t === 'reunião') return '#0ea5e9';
|
||
return isOccurrence ? '#8b5cf6' : '#6366f1'; // virtual: violet, real: indigo
|
||
}
|
||
|
||
function isoToDecimalHour(iso) {
|
||
if (!iso) return 0;
|
||
const d = new Date(iso);
|
||
return d.getHours() + d.getMinutes() / 60;
|
||
}
|
||
|
||
function normalizeForMelissa(r, paymentStateMap = null, paymentAmountMap = null, rulePaymentMap = null, ruleContractMap = null) {
|
||
// r pode vir de useAgendaEvents (linha real do banco) ou de loadAndExpand
|
||
// (ocorrência virtual com is_occurrence=true e id "rec::uuid::date").
|
||
const isOccurrence = !!r.is_occurrence;
|
||
const pacNome = r.paciente_nome ?? r.patient_name ?? r.patients?.nome_completo ?? '';
|
||
const tipo = normalizeEventoTipo(r.tipo, EVENTO_TIPO.SESSAO);
|
||
|
||
// Estado de pagamento — lido do _paymentStateMap (preenchido pelo
|
||
// _reloadRange via bulk-query em financial_records). Default 'none'
|
||
// pra ocorrências virtuais (sem id real ainda) e eventos sem record.
|
||
// 'paid' | 'pending' | 'none'.
|
||
// Fallback pra virtuais ou reais sem record: herda do contrato upfront
|
||
// pago da série via _rulePaymentMap (chave = recurrence_id).
|
||
let paymentState =
|
||
(!isOccurrence && r.id && paymentStateMap && paymentStateMap[r.id]) || null;
|
||
let paymentAmount =
|
||
!isOccurrence && r.id && paymentAmountMap ? (paymentAmountMap[r.id] ?? null) : null;
|
||
if ((!paymentState || paymentState === 'none') && r.recurrence_id && rulePaymentMap && rulePaymentMap[r.recurrence_id]) {
|
||
paymentState = rulePaymentMap[r.recurrence_id].state;
|
||
if (paymentAmount == null) paymentAmount = rulePaymentMap[r.recurrence_id].amount;
|
||
}
|
||
if (!paymentState) paymentState = 'none';
|
||
|
||
return {
|
||
id: r.id,
|
||
tipo,
|
||
status: r.status || (isOccurrence ? 'agendado' : ''),
|
||
titulo: r.titulo || r.titulo_custom || '',
|
||
patient_id: r.patient_id ?? r.paciente_id ?? null,
|
||
pacienteNome: pacNome,
|
||
modalidade: r.modalidade || '',
|
||
descricao: r.observacoes || '',
|
||
color: pickColor(tipo, r.status, isOccurrence),
|
||
label: pacNome || r.titulo || r.titulo_custom || (tipo === EVENTO_TIPO.BLOQUEIO ? 'Ocupado' : '—'),
|
||
inicio_em: r.inicio_em,
|
||
fim_em: r.fim_em,
|
||
startH: isoToDecimalHour(r.inicio_em),
|
||
endH: isoToDecimalHour(r.fim_em),
|
||
dateKey: String(r.inicio_em || '').slice(0, 10),
|
||
is_occurrence: isOccurrence,
|
||
recurrence_id: r.recurrence_id ?? null,
|
||
recurrence_date: r.recurrence_date ?? r.original_date ?? null,
|
||
paymentState,
|
||
paymentAmount,
|
||
// Info do contrato (saldo/upfront) — injetado quando a série tem
|
||
// billing_contract ativo. Popover usa pra mostrar "Pacote X · N/M".
|
||
contract: r.recurrence_id && ruleContractMap ? (ruleContractMap[r.recurrence_id] ?? null) : null,
|
||
price: r.price != null ? Number(r.price) : null,
|
||
// insurance_value: pra convênio, o valor cobrado mora aqui (não em
|
||
// price). Popover e Resumo usam fallback `price ?? insurance_value`
|
||
// pra mostrar o valor da cobrança independente do tipo.
|
||
insurance_value: r.insurance_value != null ? Number(r.insurance_value) : null,
|
||
_raw: r // ← consumido pelo MelissaLayout pra popular dialogEventRow
|
||
};
|
||
}
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// Composable principal
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// Símbolo de injeção pra MelissaAgenda recuperar o composable do MelissaLayout
|
||
// sem prop drilling. Pattern: MelissaLayout chama `provide(MELISSA_AGENDA_KEY, m)`
|
||
// e MelissaAgenda chama `inject(MELISSA_AGENDA_KEY)`.
|
||
export const MELISSA_AGENDA_KEY = Symbol('melissaAgenda');
|
||
|
||
export function useMelissaAgenda() {
|
||
const toast = useToast();
|
||
const confirm = useConfirm();
|
||
const tenantStore = useTenantStore();
|
||
|
||
// Refs do range visível — mutados pela MelissaAgenda no datesSet do FC.
|
||
// Início default: semana ao redor de hoje (FC sobrescreve no mount).
|
||
const viewStart = ref(new Date());
|
||
const viewEnd = ref(new Date());
|
||
{
|
||
const hoje = new Date();
|
||
const dow = hoje.getDay();
|
||
const diff = dow === 0 ? -6 : 1 - dow;
|
||
const segunda = new Date(hoje);
|
||
segunda.setDate(hoje.getDate() + diff);
|
||
segunda.setHours(0, 0, 0, 0);
|
||
const domingoNext = new Date(segunda);
|
||
domingoNext.setDate(segunda.getDate() + 7);
|
||
viewStart.value = segunda;
|
||
viewEnd.value = domingoNext;
|
||
}
|
||
|
||
const clinicTenantId = computed(
|
||
() => tenantStore.activeTenantId || tenantStore.tenantId || tenantStore.tenant?.id || null
|
||
);
|
||
|
||
// ── Settings + workRules ────────────────────────────────────
|
||
// cache: stale-while-revalidate via melissaCacheStore — abertura
|
||
// subsequente da Agenda na mesma sessão usa cache instantâneo.
|
||
const { settings, workRules, load: loadSettings } = useAgendaSettings({ cache: true });
|
||
// _bootUid: pegado em paralelo no mount via supabase.auth.getUser().
|
||
// Sem isso, ownerId ficava null até loadSettings completar (~300ms),
|
||
// bloqueando o primeiro fetch dos eventos. Como owner_id da agenda
|
||
// é literalmente o uid do user logado, podemos resolver imediato.
|
||
const _bootUid = ref('');
|
||
const ownerId = computed(() => settings.value?.owner_id || _bootUid.value || '');
|
||
|
||
// ── Eventos reais (CRUD) ────────────────────────────────────
|
||
const {
|
||
rows,
|
||
loading: eventsLoading,
|
||
error: eventsError,
|
||
loadMyRange,
|
||
create,
|
||
update,
|
||
remove
|
||
} = useAgendaEvents();
|
||
|
||
// ── Recorrência ─────────────────────────────────────────────
|
||
const {
|
||
loadAndExpand,
|
||
createRule,
|
||
updateRule,
|
||
cancelRule,
|
||
splitRuleAt,
|
||
cancelRuleFrom,
|
||
upsertException
|
||
} = useRecurrence();
|
||
const _occurrenceRows = ref([]);
|
||
|
||
// ── Compromissos determinados (catálogo) ────────────────────
|
||
const { rows: determinedCommitments, load: loadDeterminedCommitments } = useDeterminedCommitments(clinicTenantId);
|
||
|
||
// Adapta pro shape que o AgendaEventDialog espera (mesma lógica da
|
||
// AgendaTerapeutaPage — prioridade pra "Sessão" primeiro)
|
||
const commitmentOptions = computed(() => {
|
||
const list = Array.isArray(determinedCommitments.value) ? determinedCommitments.value : [];
|
||
const priority = new Map([
|
||
['session', 0], ['class', 1], ['study', 2],
|
||
['reading', 3], ['supervision', 4], ['content_creation', 5]
|
||
]);
|
||
return [...list]
|
||
.filter((i) => i?.id && i?.active !== false)
|
||
.sort((a, b) => {
|
||
const pa = priority.has(a.native_key) ? priority.get(a.native_key) : 99;
|
||
const pb = priority.has(b.native_key) ? priority.get(b.native_key) : 99;
|
||
if (pa !== pb) return pa - pb;
|
||
return String(a.name || '').localeCompare(String(b.name || ''), 'pt-BR');
|
||
})
|
||
.map((i) => ({
|
||
id: i.id,
|
||
tenant_id: i.tenant_id ?? null,
|
||
created_by: i.created_by ?? null,
|
||
name: String(i.name || '').trim() || 'Sem nome',
|
||
description: i.description || '',
|
||
native_key: i.native_key || null,
|
||
is_native: !!i.is_native,
|
||
is_locked: !!i.is_locked,
|
||
active: i.active !== false,
|
||
bg_color: i.bg_color || null,
|
||
text_color: i.text_color || null,
|
||
fields: Array.isArray(i.determined_commitment_fields)
|
||
? [...i.determined_commitment_fields].sort((a, b) => (a.sort_order ?? 0) - (b.sort_order ?? 0))
|
||
: []
|
||
}));
|
||
});
|
||
|
||
// ── Feriados + commitment services ──────────────────────────
|
||
// Instância única de useFeriados — antes MelissaAgenda.vue criava
|
||
// sua própria também, fazendo dupla requisição de feriados municipais
|
||
// toda vez que a agenda abria. Agora MelissaAgenda lê esses refs do
|
||
// composable injetado (M.feriadosAno, M.loadFeriadosBase, etc).
|
||
const {
|
||
todos: feriados,
|
||
fcEvents: feriadoFcEvents,
|
||
load: loadFeriadosBase,
|
||
ano: feriadosAno
|
||
} = useFeriados({ cache: true });
|
||
const { saveRuleItems, propagateToSerie } = useCommitmentServices();
|
||
|
||
// Bloqueios — renderizados como background events cinza no FullCalendar
|
||
// (entidade própria, distinta de agenda_eventos). Carregados via range
|
||
// visível + recorrentes; reativo a viewStart/viewEnd via _reloadRange.
|
||
const { bloqueios, load: loadBloqueios, buildEventsForRange: buildBloqueioEvents } = useAgendaBloqueios();
|
||
const bloqueioFcEvents = computed(() => {
|
||
const s = viewStart.value;
|
||
const e = viewEnd.value;
|
||
if (!s || !e) return [];
|
||
return buildBloqueioEvents(s, e);
|
||
});
|
||
|
||
// Detecta se um range [start, end] cai dentro de algum bloqueio carregado.
|
||
// Cobre bloqueios dia-inteiro (sem hora), com janela horária e recorrentes
|
||
// semanais (via dia_semana). Usado pra avisar o terapeuta quando ele tenta
|
||
// criar sessão em cima de bloqueio próprio — não impede, só sinaliza.
|
||
function bloqueioCobrindo(start, end) {
|
||
const arr = bloqueios?.value || [];
|
||
if (!arr.length || !start) return null;
|
||
const dStart = start instanceof Date ? start : new Date(start);
|
||
const dEnd = end instanceof Date ? end : new Date(end || start);
|
||
const isoDay = `${dStart.getFullYear()}-${String(dStart.getMonth() + 1).padStart(2, '0')}-${String(dStart.getDate()).padStart(2, '0')}`;
|
||
const dow = dStart.getDay();
|
||
const hhmmStart = dStart.getHours() * 60 + dStart.getMinutes();
|
||
const hhmmEnd = dEnd.getHours() * 60 + dEnd.getMinutes();
|
||
const parseHM = (s) => {
|
||
if (!s) return null;
|
||
const [h, m] = String(s).split(':').map(Number);
|
||
return Number.isFinite(h) ? h * 60 + (m || 0) : null;
|
||
};
|
||
for (const b of arr) {
|
||
if (!b) continue;
|
||
if (b.recorrente && b.dia_semana != null) {
|
||
if (Number(b.dia_semana) !== dow) continue;
|
||
} else {
|
||
const di = b.data_inicio;
|
||
const df = b.data_fim || b.data_inicio;
|
||
if (!di) continue;
|
||
if (isoDay < di || isoDay > df) continue;
|
||
}
|
||
const bhi = parseHM(b.hora_inicio);
|
||
const bhf = parseHM(b.hora_fim);
|
||
if (bhi == null || bhf == null) return b;
|
||
if (hhmmStart < bhf && hhmmEnd > bhi) return b;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
// ── Linhas combinadas (real + virtual) ──────────────────────
|
||
const allRows = computed(() => [...(rows.value || []), ...(_occurrenceRows.value || [])]);
|
||
|
||
// Map de estado de pagamento por agenda_evento_id, preenchido pelo
|
||
// _reloadRange via bulk-query em financial_records. Permite renderizar
|
||
// badge "$ pendente" no FC e linha "A receber" no popover sem ter que
|
||
// queryar por evento. 'paid' | 'pending'. Eventos não listados = 'none'.
|
||
const _paymentStateMap = ref({});
|
||
// Map evento_id → valor pago/cobrado (final_amount do record). Usado pelo
|
||
// popover e Resumo do dialog quando paymentState='paid' pra mostrar o
|
||
// valor REAL pago (vs evento.price que pode ter sido editado depois).
|
||
const _paymentAmountMap = ref({});
|
||
// Map recurrence_id → {state, amount}. Pra ocorrências virtuais (que não
|
||
// têm id real e portanto não entram em _paymentStateMap), normalize lê
|
||
// daqui o estado herdado do contrato upfront pago da série.
|
||
const _rulePaymentMap = ref({});
|
||
// Map recurrence_id → {style, totalSessions, sessionsUsed, packagePrice}
|
||
// — info do billing_contract da série pra exibir no popover.
|
||
const _ruleContractMap = ref({});
|
||
|
||
// ── Eventos Melissa-normalizados (consumidos pelo MelissaAgenda FC) ──
|
||
const eventos = computed(() => allRows.value.map((r) => normalizeForMelissa(r, _paymentStateMap.value, _paymentAmountMap.value, _rulePaymentMap.value, _ruleContractMap.value)));
|
||
|
||
// ── Eventos do FC original (se precisar — AgendaEventDialog quer
|
||
// `allEvents` no shape FC pra checar conflitos) ─────────
|
||
const allEventsForDialog = computed(() =>
|
||
allRows.value.map((r) => ({
|
||
id: r.id,
|
||
start: r.inicio_em,
|
||
end: r.fim_em,
|
||
extendedProps: { ...r }
|
||
}))
|
||
);
|
||
|
||
// ── Dialog state ────────────────────────────────────────────
|
||
const dialogOpen = ref(false);
|
||
const dialogEventRow = ref(null);
|
||
const dialogStartISO = ref('');
|
||
const dialogEndISO = ref('');
|
||
// Aviso quando o slot escolhido cai em cima de um bloqueio próprio.
|
||
// Renderizado pelo AgendaEventDialog no topo do step 1 (Message warn).
|
||
// null = sem aviso; { titulo } = aviso ativo.
|
||
const dialogBlockOverlap = ref(null);
|
||
|
||
// Segundo dialog (empilhado por cima do principal) — usado pra editar
|
||
// uma OCORRÊNCIA específica de uma série. Acionado pelo botão "Editar"
|
||
// de cada pill na lista "Recorrências Aplicadas" do AgendaEventDialog.
|
||
// Adicionado 2026-05-11 — espelho pra Rail/Clínica está pendente.
|
||
const occDialogOpen = ref(false);
|
||
const occDialogEventRow = ref(null);
|
||
const occDialogStartISO = ref('');
|
||
const occDialogEndISO = ref('');
|
||
|
||
// serieRefreshTick: bump quando o 2º dialog (ocorrencia) faz save com
|
||
// sucesso. AgendaEventDialog do dialog PAI observa essa prop e re-roda
|
||
// loadSerieEvents pra atualizar a lista de pills "Recorrencias Aplicadas".
|
||
// Sem isso, pills ficavam com status stale ate fechar/reabrir o pai. 2026-05-12.
|
||
const serieRefreshTick = ref(0);
|
||
|
||
// Status change confirm dialog (Fase 5, 2026-05-14) — aparece quando
|
||
// user muda status pra realizado/faltou/cancelado e há decisão a tomar
|
||
// (regra de exceção, pacote saldo, pending record).
|
||
const statusDialogOpen = ref(false);
|
||
const statusDialogProps = ref({});
|
||
// Resolver guardado fora do ref pra não vazar pro template via reactivity.
|
||
let _statusDialogResolve = null;
|
||
function _openStatusDialog(propsObj) {
|
||
return new Promise((resolve) => {
|
||
statusDialogProps.value = propsObj;
|
||
statusDialogOpen.value = true;
|
||
_statusDialogResolve = resolve;
|
||
});
|
||
}
|
||
function onStatusDialogConfirm(decision) {
|
||
if (_statusDialogResolve) _statusDialogResolve(decision);
|
||
_statusDialogResolve = null;
|
||
}
|
||
function onStatusDialogCancel() {
|
||
// PrimeVue Dialog dispara update:visible(false) ao fechar via X/Esc;
|
||
// tratar isso aqui ainda — resolve com null pra cancelar.
|
||
if (_statusDialogResolve) _statusDialogResolve(null);
|
||
_statusDialogResolve = null;
|
||
}
|
||
|
||
// Bloqueio dialog (modo: 'horario' | 'periodo' | 'dia' | 'feriados')
|
||
const bloqueioDialogOpen = ref(false);
|
||
const bloqueioMode = ref('horario');
|
||
function openBloqueioDialog(mode) {
|
||
if (!ownerId.value) {
|
||
toast.add({
|
||
severity: 'warn',
|
||
summary: 'Agenda',
|
||
detail: 'Aguarde carregar as configurações da agenda.',
|
||
life: 3000
|
||
});
|
||
return;
|
||
}
|
||
bloqueioMode.value = mode || 'horario';
|
||
bloqueioDialogOpen.value = true;
|
||
}
|
||
|
||
// ── Refetch (com merge real+virtual) ────────────────────────
|
||
async function _reloadRange() {
|
||
const s = viewStart.value;
|
||
const e = viewEnd.value;
|
||
if (!s || !e) return;
|
||
|
||
// Espera ownerId E tenant — qualquer um faltando significa boot
|
||
// ainda em curso (auth/tenantStore/settings async). Watcher one-shot
|
||
// re-dispara assim que o último ficar disponível, sem polling.
|
||
if (!ownerId.value || !clinicTenantId.value) {
|
||
const unwatch = watch(
|
||
() => [ownerId.value, clinicTenantId.value],
|
||
([uid, tid]) => {
|
||
if (!uid || !tid) return;
|
||
unwatch();
|
||
_reloadRange();
|
||
}
|
||
);
|
||
return;
|
||
}
|
||
|
||
const start = new Date(s);
|
||
const end = new Date(e);
|
||
const tid = clinicTenantId.value;
|
||
|
||
// Etapa 1: eventos reais — `rows` é reativo, FullCalendar re-renderiza
|
||
// assim que esse await resolve (o user já vê as sessões agendadas).
|
||
await loadMyRange(start.toISOString(), end.toISOString(), ownerId.value);
|
||
|
||
// Etapa 2: ocorrências virtuais (regras de recorrência expandidas).
|
||
// Continuamos awaitando porque saveRule/cancel dependem do estado
|
||
// final estar pronto pra UI consistente, mas a janela visual onde
|
||
// o usuário vê só eventos reais é a metade do tempo de antes.
|
||
const merged = await loadAndExpand(ownerId.value, start, end, rows.value, tid);
|
||
_occurrenceRows.value = merged.filter((r) => r.is_occurrence);
|
||
|
||
// Etapa 3: bloqueios (background events). Não bloqueia render do
|
||
// calendário — load assíncrono em paralelo. bloqueioFcEvents é computed
|
||
// e re-renderiza quando bloqueios.value atualizar.
|
||
loadBloqueios(ownerId.value, start, end);
|
||
|
||
// Etapa 4: estado de pagamento — bulk-query 1x pra todos os eventos
|
||
// reais visíveis. Annotação 'paid' | 'pending' fica no _paymentStateMap;
|
||
// o computed `eventos` re-corre normalizeForMelissa quando o map muda.
|
||
// Não bloqueia render — eventos aparecem sem badge até este await
|
||
// resolver (default 'none').
|
||
const realIds = (rows.value || []).map((r) => r.id).filter(Boolean);
|
||
// Inicializa maps SEMPRE (não condicional a realIds.length>0) — em
|
||
// semanas onde só há virtuais (sem eventos reais), ainda precisamos
|
||
// rodar a propagação cross-week pra ruleMap.
|
||
const map = {};
|
||
const amountMap = {};
|
||
const ruleMap = {};
|
||
const ruleContractMap = {};
|
||
// Filtra cancelados: cobrança cancelada não deve manter
|
||
// paymentState='pending' (badge $ residual). Tratamos cancelled
|
||
// como "sem cobrança ativa" → cai pro default 'none'.
|
||
if (realIds.length) {
|
||
const { data: recs } = await supabase
|
||
.from('financial_records')
|
||
.select('agenda_evento_id, paid_at, status, amount, final_amount')
|
||
.in('agenda_evento_id', realIds)
|
||
.neq('status', 'cancelled');
|
||
for (const id of realIds) map[id] = 'none';
|
||
for (const rec of recs || []) {
|
||
const eid = rec.agenda_evento_id;
|
||
if (!eid) continue;
|
||
if (rec.paid_at) {
|
||
// Paid override: se QUALQUER record dessa sessão está paid,
|
||
// consideramos cobrança honrada. Filhos (multas/taxas) que
|
||
// venham pendentes não revertem esse estado pro badge.
|
||
map[eid] = 'paid';
|
||
amountMap[eid] = rec.final_amount ?? rec.amount ?? null;
|
||
} else if (map[eid] !== 'paid') {
|
||
map[eid] = 'pending';
|
||
if (amountMap[eid] == null) amountMap[eid] = rec.final_amount ?? rec.amount ?? null;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Propagação de pacote UPFRONT — SEMPRE roda (mesmo sem eventos reais
|
||
// na view), pra que virtuais isoladas em semanas futuras herdem o
|
||
// estado do contrato cross-week. Antes estava dentro de
|
||
// `if (realIds.length)` e falhava em semanas só com virtuais.
|
||
//
|
||
// Quando a 1ª materializada
|
||
// de uma série tem record (paid OU pending), TODAS as ocorrências
|
||
// do mesmo recurrence_id devem herdar o mesmo estado (cobertas
|
||
// pelo contrato — cobrança única do pacote inteiro).
|
||
// Funciona pra real rows; virtuais ficam por loadOccFinancialRecord
|
||
// no occurrenceMode (não passam por este bulk).
|
||
// Estratégia: pega rules dos eventos com record OU dos eventos
|
||
// visíveis que têm recurrence_id (cross-week — record do pacote
|
||
// pode estar em outra semana), busca contratos upfront ativos
|
||
// pros pacientes, propaga estado pra siblings no view atual.
|
||
try {
|
||
// 1) Coleta rule_ids de TODOS os eventos visíveis que pertencem
|
||
// a uma série. Antes só usávamos eventos COM record paid/pending,
|
||
// o que falhava cross-week (record do pacote pode estar em
|
||
// outra semana). Agora propagação cobre Ana 2/3/4 mesmo
|
||
// quando o record está só no Ana 1.
|
||
const ruleIdsInView = new Set();
|
||
for (const r of rows.value || []) {
|
||
if (r.recurrence_id) ruleIdsInView.add(r.recurrence_id);
|
||
}
|
||
// Virtuais expandidas (que vêm depois) também trazem rules:
|
||
for (const r of _occurrenceRows.value || []) {
|
||
if (r.recurrence_id) ruleIdsInView.add(r.recurrence_id);
|
||
}
|
||
if (ruleIdsInView.size) {
|
||
// 2) Acha patient_id direto das rules (fonte autoritativa,
|
||
// funciona até quando rule não tem nenhum evento
|
||
// materializado — caso pacote saldo recém-criado).
|
||
const { data: rulesData } = await supabase
|
||
.from('recurrence_rules')
|
||
.select('id, patient_id')
|
||
.in('id', [...ruleIdsInView]);
|
||
const rulePatientFromRule = new Map();
|
||
for (const r of rulesData || []) {
|
||
if (r.patient_id) rulePatientFromRule.set(r.id, r.patient_id);
|
||
}
|
||
|
||
// 3) Acha eventos (em qualquer semana) das rules em view +
|
||
// seus records paid/pending pra detectar estado.
|
||
const { data: allRuleEvents } = await supabase
|
||
.from('agenda_eventos')
|
||
.select('id, recurrence_id, patient_id')
|
||
.in('recurrence_id', [...ruleIdsInView]);
|
||
const ruleEventIds = (allRuleEvents || []).map((e) => e.id);
|
||
let ruleRecords = [];
|
||
if (ruleEventIds.length) {
|
||
const { data: rr } = await supabase
|
||
.from('financial_records')
|
||
.select('agenda_evento_id, paid_at, status')
|
||
.in('agenda_evento_id', ruleEventIds)
|
||
.neq('status', 'cancelled');
|
||
ruleRecords = rr || [];
|
||
}
|
||
// Mapeia: rule_id → state (paid > pending > none) + patient
|
||
const evIdToRule = new Map();
|
||
const evIdToPatient = new Map();
|
||
for (const e of allRuleEvents || []) {
|
||
evIdToRule.set(e.id, e.recurrence_id);
|
||
evIdToPatient.set(e.id, e.patient_id);
|
||
}
|
||
const ruleToPatient = new Map();
|
||
const ruleToState = new Map();
|
||
for (const rec of ruleRecords) {
|
||
const rid = evIdToRule.get(rec.agenda_evento_id);
|
||
const pid = evIdToPatient.get(rec.agenda_evento_id);
|
||
if (!rid || !pid) continue;
|
||
ruleToPatient.set(rid, pid);
|
||
const newState = rec.paid_at ? 'paid' : 'pending';
|
||
const existing = ruleToState.get(rid);
|
||
if (existing !== 'paid') ruleToState.set(rid, newState);
|
||
}
|
||
// Busca contratos ativos pra TODOS pacientes envolvidos
|
||
// (saldo OU upfront — ambos exibem info no popover).
|
||
// Query única com todos campos necessários. Usa
|
||
// rulePatientFromRule (fonte autoritativa) pra cobrir
|
||
// saldo sem records (não passa por ruleToPatient).
|
||
const allPatientIds = [...new Set(rulePatientFromRule.values())];
|
||
let activePackages = [];
|
||
if (allPatientIds.length) {
|
||
const { data: contracts } = await supabase
|
||
.from('billing_contracts')
|
||
.select('id, patient_id, charging_style, status, type, total_sessions, sessions_used, package_price')
|
||
.in('patient_id', allPatientIds);
|
||
activePackages = (contracts || []).filter((c) => c.type === 'package' && c.status === 'active');
|
||
}
|
||
// Index por patient_id pra lookup rápido
|
||
const contractByPatient = new Map();
|
||
for (const c of activePackages) contractByPatient.set(c.patient_id, c);
|
||
// Popula ruleContractMap pra TODAS as rules em view com
|
||
// contrato ativo (saldo + upfront, com OU sem records).
|
||
for (const [rid, pid] of rulePatientFromRule.entries()) {
|
||
const c = contractByPatient.get(pid);
|
||
if (c) {
|
||
ruleContractMap[rid] = {
|
||
id: c.id,
|
||
style: c.charging_style || 'upfront',
|
||
totalSessions: c.total_sessions || 0,
|
||
sessionsUsed: c.sessions_used || 0,
|
||
packagePrice: Number(c.package_price || 0)
|
||
};
|
||
}
|
||
}
|
||
if (ruleToPatient.size) {
|
||
// NULL charging_style → assume upfront (default histórico
|
||
// antes da migration 20260514000003). Pra dados antigos
|
||
// sem a coluna preenchida, evita virtuais ficarem sem
|
||
// propagação. 'saldo' explícito mantém siblings 'none'.
|
||
const upfrontPatients = new Set(
|
||
activePackages
|
||
.filter((c) => c.charging_style === 'upfront' || c.charging_style == null)
|
||
.map((c) => c.patient_id)
|
||
);
|
||
const ruleIdsToPropagate = [...ruleToPatient.entries()]
|
||
.filter(([, pid]) => upfrontPatients.has(pid))
|
||
.map(([rid]) => rid);
|
||
if (ruleIdsToPropagate.length) {
|
||
const { data: siblings } = await supabase
|
||
.from('agenda_eventos')
|
||
.select('id, recurrence_id')
|
||
.in('recurrence_id', ruleIdsToPropagate);
|
||
// Reusa contractByPatient da query unificada acima
|
||
// (antes havia uma 2ª query redundante pro mesmo dado).
|
||
const contractPriceByPatient = new Map();
|
||
for (const c of activePackages) {
|
||
if (c.charging_style === 'upfront' || c.charging_style == null) {
|
||
contractPriceByPatient.set(c.patient_id, c.package_price);
|
||
}
|
||
}
|
||
for (const s of siblings || []) {
|
||
if (map[s.id] !== undefined) {
|
||
const stateToPropagate = ruleToState.get(s.recurrence_id) || 'pending';
|
||
// Não rebaixa: se sibling já está paid
|
||
// (record próprio paid), mantém.
|
||
if (map[s.id] !== 'paid') map[s.id] = stateToPropagate;
|
||
const pid = ruleToPatient.get(s.recurrence_id);
|
||
const price = contractPriceByPatient.get(pid);
|
||
if (price != null) amountMap[s.id] = price;
|
||
}
|
||
}
|
||
// Populate _rulePaymentMap pra que VIRTUAIS (ainda
|
||
// não materializadas) também herdem o estado ao
|
||
// passar pelo normalize.
|
||
for (const rid of ruleIdsToPropagate) {
|
||
const pid = ruleToPatient.get(rid);
|
||
const price = contractPriceByPatient.get(pid);
|
||
const state = ruleToState.get(rid) || 'pending';
|
||
ruleMap[rid] = { state, amount: price ?? null };
|
||
}
|
||
}
|
||
}
|
||
}
|
||
} catch (e) {
|
||
console.warn('[useMelissaAgenda] propagação upfront falhou:', e?.message);
|
||
}
|
||
|
||
_paymentStateMap.value = map;
|
||
_paymentAmountMap.value = amountMap;
|
||
_rulePaymentMap.value = ruleMap;
|
||
_ruleContractMap.value = ruleContractMap;
|
||
}
|
||
|
||
async function refetch() {
|
||
await _reloadRange();
|
||
}
|
||
|
||
// ── Inicialização ───────────────────────────────────────────
|
||
// Boot paralelo: auth uid + tenant + settings todos disparam ao mesmo
|
||
// tempo. Antes era serial (loadSettings precisava terminar pra ownerId
|
||
// ficar disponível e o watch disparar _reloadRange) — adicionava ~300ms
|
||
// de waterfall antes da primeira query de eventos sair.
|
||
onMounted(() => {
|
||
// 1) Resolve o uid o quanto antes — destrava _reloadRange.
|
||
// getSession() lê do storage local (fast path, <10ms);
|
||
// getUser() faria round-trip pro auth server. Fallback pro
|
||
// getUser só se a sessão ainda não estiver no storage.
|
||
supabase.auth.getSession()
|
||
.then(({ data }) => {
|
||
const uid = data?.session?.user?.id;
|
||
if (uid) {
|
||
_bootUid.value = uid;
|
||
} else {
|
||
// Cold start sem sessão hidratada — fallback pro round-trip.
|
||
return supabase.auth.getUser().then(({ data: u }) => {
|
||
if (u?.user?.id) _bootUid.value = u.user.id;
|
||
});
|
||
}
|
||
})
|
||
.catch(() => { /* noop — settings ainda pode resolver */ });
|
||
|
||
// 2) Garante que o tenant está hidratado (idempotente — se já
|
||
// estiver carregado, retorna imediato).
|
||
if (typeof tenantStore.ensureLoaded === 'function') {
|
||
tenantStore.ensureLoaded().catch(() => {});
|
||
}
|
||
|
||
// 3) Settings em paralelo (não bloqueia mais nada)
|
||
loadSettings();
|
||
});
|
||
|
||
// Refetch settings + workRules quando o user salva jornada/ritmo/online
|
||
// em /configuracoes/agenda (embedado no Melissa). Sem isso, a timeline
|
||
// do resumo continuaria mostrando o range antigo até reload da página.
|
||
function _onSettingsSaved() {
|
||
loadSettings();
|
||
}
|
||
onMounted(() => {
|
||
window.addEventListener('agenda:settings-saved', _onSettingsSaved);
|
||
});
|
||
onBeforeUnmount(() => {
|
||
window.removeEventListener('agenda:settings-saved', _onSettingsSaved);
|
||
});
|
||
|
||
// Commitments + feriados dependem do tenant. Em refresh "frio", o
|
||
// tenantStore ainda não terminou de hidratar quando o composable
|
||
// monta, e clinicTenantId fica null. loadDeterminedCommitments faz
|
||
// bail-out silencioso quando tenantId é vazio (rows = [], sem retry)
|
||
// — daí o "às vezes" do bug onde commitmentOptions chegava vazio no
|
||
// AgendaEventDialog. Watch com immediate: true dispara já se o tenant
|
||
// estiver pronto, ou no momento exato em que ele aparecer.
|
||
watch(
|
||
clinicTenantId,
|
||
async (tid) => {
|
||
if (!tid) return;
|
||
await loadDeterminedCommitments();
|
||
await loadFeriadosBase(tid);
|
||
},
|
||
{ immediate: true }
|
||
);
|
||
|
||
// Reload quando o range visível muda. _reloadRange já tem guard
|
||
// interno pra esperar uid+tenant (one-shot watcher) — sem necessidade
|
||
// de outro watch global em ownerId, que disparava _reloadRange duplicado.
|
||
watch([viewStart, viewEnd], _reloadRange);
|
||
|
||
// ──────────────────────────────────────────────────────────
|
||
// Handlers — populados na Stage 2
|
||
// ──────────────────────────────────────────────────────────
|
||
const _stageTwo = {}; // placeholder pra próxima passada
|
||
Object.assign(_stageTwo, _buildHandlers({
|
||
toast, confirm, supabase,
|
||
ownerId, clinicTenantId, settings,
|
||
rows, allRows, eventsError,
|
||
create, update, remove,
|
||
loadAndExpand, createRule, updateRule, cancelRule, splitRuleAt, cancelRuleFrom, upsertException,
|
||
saveRuleItems, propagateToSerie,
|
||
dialogOpen, dialogEventRow, dialogStartISO, dialogEndISO,
|
||
occDialogOpen, occDialogEventRow, occDialogStartISO, occDialogEndISO,
|
||
serieRefreshTick,
|
||
// Fase 5: dialog de status change vive no escopo do composable
|
||
// (refs ficam aqui pra expor pro template); _buildHandlers precisa
|
||
// do opener pra abrir e aguardar decisão antes de aplicar.
|
||
_openStatusDialog,
|
||
_reloadRange,
|
||
bloqueioCobrindo,
|
||
dialogBlockOverlap
|
||
}));
|
||
|
||
return {
|
||
// View range (mutado pela MelissaAgenda via FC.datesSet)
|
||
viewStart,
|
||
viewEnd,
|
||
|
||
// Eventos pro FC
|
||
eventos,
|
||
rawRows: allRows,
|
||
loading: eventsLoading,
|
||
refetch,
|
||
|
||
// Dialog state
|
||
dialogOpen,
|
||
dialogEventRow,
|
||
dialogStartISO,
|
||
dialogEndISO,
|
||
dialogBlockOverlap,
|
||
|
||
// 2º dialog (edit ocorrência da série, empilhado por cima)
|
||
occDialogOpen,
|
||
occDialogEventRow,
|
||
occDialogStartISO,
|
||
occDialogEndISO,
|
||
serieRefreshTick,
|
||
|
||
// Bloqueio dialog
|
||
bloqueioDialogOpen,
|
||
bloqueioMode,
|
||
openBloqueioDialog,
|
||
|
||
// Status change confirm dialog (Fase 5)
|
||
statusDialogOpen,
|
||
statusDialogProps,
|
||
onStatusDialogConfirm,
|
||
onStatusDialogCancel,
|
||
|
||
// Settings + commitments + feriados (props pro AgendaEventDialog)
|
||
settings,
|
||
workRules,
|
||
ownerId,
|
||
clinicTenantId,
|
||
commitmentOptions,
|
||
feriados,
|
||
feriadoFcEvents,
|
||
feriadosAno,
|
||
loadFeriadosBase,
|
||
allEventsForDialog,
|
||
|
||
// Bloqueios (background events cinza no FC)
|
||
bloqueios,
|
||
bloqueioFcEvents,
|
||
|
||
// Handlers
|
||
..._stageTwo
|
||
};
|
||
}
|
||
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
// Handlers — extraídos numa função à parte só por organização (mantém o
|
||
// composable principal legível). Stage 2 preenche as funções abaixo.
|
||
// ───────────────────────────────────────────────────────────────────────────
|
||
function _buildHandlers(deps) {
|
||
// Só desempacota o que os handlers desta função usam diretamente —
|
||
// _buildOnDialogSave/_buildOnDialogDelete recebem `deps` completo.
|
||
const {
|
||
toast, confirm,
|
||
ownerId, clinicTenantId, settings,
|
||
allRows, eventsError,
|
||
create, update,
|
||
dialogOpen, dialogEventRow, dialogStartISO, dialogEndISO,
|
||
occDialogOpen, occDialogEventRow, occDialogStartISO, occDialogEndISO,
|
||
_openStatusDialog,
|
||
bloqueioCobrindo,
|
||
dialogBlockOverlap
|
||
} = deps;
|
||
|
||
// Helpers de formatação pra mensagens de confirm/toast (PT-BR estilo
|
||
// "16h" / "15:15h" / "27/04").
|
||
function _fmtH(d) {
|
||
if (!d) return '?';
|
||
const h = d.getHours();
|
||
const m = d.getMinutes();
|
||
return m === 0 ? `${h}h` : `${h}:${String(m).padStart(2, '0')}h`;
|
||
}
|
||
function _fmtD(d) {
|
||
if (!d) return '';
|
||
return `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||
}
|
||
// Escape HTML — usado quando a mensagem do confirm tem partes
|
||
// controladas pelo usuário (nome do paciente) e v-html no slot.
|
||
function _esc(s) {
|
||
return String(s ?? '')
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
// ── onEditEvento — chamado pelo MelissaEventoPanel ao clicar "Editar" ──
|
||
// Recebe o `_raw` normalizado e popula o dialogEventRow no shape esperado
|
||
// pelo AgendaEventDialog (mesma estrutura de AgendaTerapeutaPage.onEventClick).
|
||
function onEditEvento(rawRow) {
|
||
if (!rawRow) return;
|
||
dialogEventRow.value = {
|
||
id: rawRow.is_occurrence ? null : rawRow.id || null,
|
||
owner_id: rawRow.owner_id ?? ownerId.value,
|
||
terapeuta_id: rawRow.terapeuta_id ?? null,
|
||
paciente_id: rawRow.patient_id ?? rawRow.paciente_id ?? null,
|
||
paciente_nome: rawRow.paciente_nome ?? rawRow.patient_name ?? rawRow.patients?.nome_completo ?? null,
|
||
paciente_avatar: rawRow.paciente_avatar ?? rawRow.patients?.avatar_url ?? null,
|
||
paciente_status: rawRow.paciente_status ?? rawRow.patients?.status ?? null,
|
||
tipo: rawRow.tipo || 'sessao',
|
||
status: rawRow.status,
|
||
titulo: rawRow.titulo,
|
||
observacoes: rawRow.observacoes ?? null,
|
||
visibility_scope: rawRow.visibility_scope ?? 'public',
|
||
inicio_em: rawRow.inicio_em,
|
||
fim_em: rawRow.fim_em,
|
||
modalidade: rawRow.modalidade ?? null,
|
||
determined_commitment_id: rawRow.determined_commitment_id ?? null,
|
||
titulo_custom: rawRow.titulo_custom ?? null,
|
||
extra_fields: rawRow.extra_fields ?? null,
|
||
price: rawRow.price != null ? Number(rawRow.price) : null,
|
||
insurance_plan_id: rawRow.insurance_plan_id ?? null,
|
||
insurance_guide_number: rawRow.insurance_guide_number ?? null,
|
||
insurance_value: rawRow.insurance_value != null ? Number(rawRow.insurance_value) : null,
|
||
insurance_plan_service_id: rawRow.insurance_plan_service_id ?? null,
|
||
// recorrência
|
||
recurrence_id: rawRow.recurrence_id ?? null,
|
||
original_date: rawRow.original_date ?? rawRow.recurrence_date ?? null,
|
||
is_occurrence: !!rawRow.is_occurrence,
|
||
exception_type: rawRow.exception_type ?? rawRow.exceptionType ?? null,
|
||
// legado
|
||
serie_id: rawRow.serie_id ?? rawRow.recurrence_id ?? null,
|
||
serie_dia_semana: rawRow.serie_dia_semana ?? null,
|
||
serie_hora: rawRow.serie_hora ?? null
|
||
};
|
||
dialogStartISO.value = '';
|
||
dialogEndISO.value = '';
|
||
dialogBlockOverlap.value = null;
|
||
dialogOpen.value = true;
|
||
}
|
||
|
||
// ── onCreateEvento — botão "+ Agendar" sem seleção no FC ──
|
||
// Espelha AgendaTerapeutaPage.onCreateFromButton:1423 — usa o dia
|
||
// visualizado + hora atual como defaults razoáveis.
|
||
function onCreateEvento() {
|
||
if (!ownerId.value) {
|
||
toast.add({
|
||
severity: 'warn',
|
||
summary: 'Agenda',
|
||
detail: 'Aguarde carregar as configurações da agenda.',
|
||
life: 3000
|
||
});
|
||
return;
|
||
}
|
||
const durMin =
|
||
settings.value?.session_duration_min ??
|
||
settings.value?.duracao_padrao_minutos ??
|
||
50;
|
||
const base = new Date();
|
||
// Arredonda pra próximo slot de 15min — UX mais limpa que minuto solto.
|
||
base.setSeconds(0, 0);
|
||
const remainder = base.getMinutes() % 15;
|
||
if (remainder !== 0) {
|
||
base.setMinutes(base.getMinutes() + (15 - remainder));
|
||
}
|
||
|
||
dialogEventRow.value = {
|
||
owner_id: ownerId.value,
|
||
terapeuta_id: null,
|
||
paciente_id: null,
|
||
tipo: EVENTO_TIPO.SESSAO,
|
||
status: 'agendado',
|
||
titulo: deriveTituloDefaultByTipo(EVENTO_TIPO.SESSAO),
|
||
observacoes: null,
|
||
visibility_scope: 'public',
|
||
determined_commitment_id: null
|
||
};
|
||
dialogStartISO.value = base.toISOString();
|
||
dialogEndISO.value = new Date(base.getTime() + durMin * 60000).toISOString();
|
||
dialogBlockOverlap.value = null;
|
||
dialogOpen.value = true;
|
||
}
|
||
|
||
// ── onCreateEventoForPatient — abre o AgendaEventDialog com paciente
|
||
// pre-selecionado. Usado pelo MelissaPaciente quando o user clica
|
||
// "Agendar" na sidebar Acoes Rapidas. Mesma logica de onCreateEvento
|
||
// (defaults razoaveis: hoje proximo slot 15min, duracao default), so
|
||
// que injeta paciente_id no dialogEventRow.
|
||
function onCreateEventoForPatient(patientId) {
|
||
if (!ownerId.value) {
|
||
toast.add({
|
||
severity: 'warn',
|
||
summary: 'Agenda',
|
||
detail: 'Aguarde carregar as configurações da agenda.',
|
||
life: 3000
|
||
});
|
||
return;
|
||
}
|
||
const durMin =
|
||
settings.value?.session_duration_min ??
|
||
settings.value?.duracao_padrao_minutos ??
|
||
50;
|
||
const base = new Date();
|
||
base.setSeconds(0, 0);
|
||
const remainder = base.getMinutes() % 15;
|
||
if (remainder !== 0) {
|
||
base.setMinutes(base.getMinutes() + (15 - remainder));
|
||
}
|
||
|
||
dialogEventRow.value = {
|
||
owner_id: ownerId.value,
|
||
terapeuta_id: null,
|
||
paciente_id: patientId ? String(patientId) : null,
|
||
tipo: EVENTO_TIPO.SESSAO,
|
||
status: 'agendado',
|
||
titulo: deriveTituloDefaultByTipo(EVENTO_TIPO.SESSAO),
|
||
observacoes: null,
|
||
visibility_scope: 'public',
|
||
determined_commitment_id: null
|
||
};
|
||
dialogStartISO.value = base.toISOString();
|
||
dialogEndISO.value = new Date(base.getTime() + durMin * 60000).toISOString();
|
||
dialogBlockOverlap.value = null;
|
||
dialogOpen.value = true;
|
||
}
|
||
|
||
// ── onSelectTime — click-drag no FC pra criar evento novo ──
|
||
// Dinâmica de duração:
|
||
// click sem drag → settings.session_duration_min (default 50)
|
||
// drag ≤ default → default (mínimo da configuração)
|
||
// drag entre default e MAX_SESSION_MINUTES → respeita o drag (snap em
|
||
// 15min já feito pelo FC via snapDuration)
|
||
// drag > MAX → capa em MAX (regra de negócio Melissa: 2h
|
||
// é o slot máximo permitido)
|
||
function onSelectTime(selection) {
|
||
const defaultDur =
|
||
settings.value?.session_duration_min ??
|
||
settings.value?.duracao_padrao_minutos ??
|
||
50;
|
||
|
||
const rawStart = selection.start instanceof Date ? selection.start : new Date(selection.start);
|
||
const rawEnd = selection.end
|
||
? (selection.end instanceof Date ? selection.end : new Date(selection.end))
|
||
: null;
|
||
const dragMin = rawEnd ? Math.round((rawEnd.getTime() - rawStart.getTime()) / 60000) : 0;
|
||
|
||
const durMin =
|
||
dragMin <= defaultDur ? defaultDur
|
||
: dragMin <= MAX_SESSION_MINUTES ? dragMin
|
||
: MAX_SESSION_MINUTES;
|
||
|
||
const startISO = rawStart.toISOString();
|
||
const endISO = new Date(rawStart.getTime() + durMin * 60000).toISOString();
|
||
|
||
// Aviso de bloqueio — não veta criação (só agendador público veta),
|
||
// mas sinaliza pro terapeuta que tá agendando em cima de bloqueio.
|
||
// Renderizado como Message no topo do step 1 do AgendaEventDialog
|
||
// (toast antigo ficava atrás do overlay do dialog).
|
||
const bloqHit = bloqueioCobrindo(rawStart, new Date(rawStart.getTime() + durMin * 60000));
|
||
dialogBlockOverlap.value = bloqHit ? { titulo: bloqHit.titulo || 'Bloqueio' } : null;
|
||
|
||
dialogEventRow.value = {
|
||
owner_id: ownerId.value,
|
||
terapeuta_id: null,
|
||
paciente_id: null,
|
||
tipo: EVENTO_TIPO.SESSAO,
|
||
status: 'agendado',
|
||
titulo: deriveTituloDefaultByTipo(EVENTO_TIPO.SESSAO),
|
||
observacoes: null,
|
||
visibility_scope: 'public',
|
||
determined_commitment_id: null
|
||
};
|
||
dialogStartISO.value = startISO;
|
||
dialogEndISO.value = endISO;
|
||
dialogOpen.value = true;
|
||
}
|
||
|
||
// ── persistMoveOrResize — drag/resize do FC ──
|
||
// Fluxo:
|
||
// 1. Bail se for ocorrência virtual (sem id real)
|
||
// 2. Conflict-check ANTES de confirmar (revert+toast imediato evita
|
||
// sequência ruim de "confirma? > ah não, conflito")
|
||
// 3. Confirm dialog descrevendo a alteração ("trocando Leonardo de
|
||
// 16h para 15:15h, confirma?")
|
||
// 4. Se aceitar, UPDATE + toast success. Se recusar, revert.
|
||
async function persistMoveOrResize(info, actionLabel) {
|
||
try {
|
||
const ev = info?.event;
|
||
if (!ev) return;
|
||
const id = ev.id;
|
||
|
||
if (!id || !isUuid(id)) {
|
||
info?.revert?.();
|
||
toast.add({
|
||
severity: 'info',
|
||
summary: 'Sessão recorrente',
|
||
detail: 'Para mover uma sessão da série, abra-a e edite com "Somente esta sessão".',
|
||
life: 4500
|
||
});
|
||
return;
|
||
}
|
||
|
||
const startISO = ev.start ? ev.start.toISOString() : null;
|
||
const endISO = ev.end ? ev.end.toISOString() : null;
|
||
if (!startISO || !endISO) throw new Error('Compromisso sem start/end.');
|
||
|
||
const start = new Date(startISO);
|
||
const end = new Date(endISO);
|
||
const breakMin = settings.value?.session_break_min || 0;
|
||
|
||
const conflict = allRows.value.find((r) => {
|
||
if (!r.inicio_em || r.id === id) return false;
|
||
const rS = new Date(r.inicio_em);
|
||
const rE = new Date(r.fim_em || r.inicio_em);
|
||
return start < new Date(rE.getTime() + breakMin * 60000) && end > rS;
|
||
});
|
||
|
||
if (conflict) {
|
||
info?.revert?.();
|
||
toast.add({ severity: 'warn', summary: 'Conflito', detail: 'Já existe um compromisso neste horário.', life: 4000 });
|
||
return;
|
||
}
|
||
|
||
// Confirm: monta mensagem descrevendo o que mudou. Usa HTML
|
||
// (renderizado via v-html no slot #message do ConfirmDialog em
|
||
// MelissaLayout) pra destacar datas/horas em <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}`;
|
||
}
|