Files
agenciapsilmno/src/features/agenda/composables/useAgendaEventLifecycle.js
T
Leonardo a7f6bcbe66 F3 schema-per-tenant: frontend usa tenantDb() pra tabelas tenant
- useTenantDb composable + lib/supabase/tenantClient (tenantDb/tenantSchemaName)
- tenantStore: getters activeTenantSlug/activeTenantSchema; my_tenants() RPC
  passa a devolver slug+nome (migration 07)
- codemod scripts/codemod-tenant-db.py: supabase.from('<84 tabelas + 6 views
  tenant>') -> tenantDb().from(...) em 139 arquivos (777 chamadas), remove
  .eq('tenant_id') das cadeias tenant (173)
- passada manual (4 agentes): remove tenant_id de payloads insert/upsert/update,
  selects, .or/.is de defaults; onConflict ajustado pros uniques sem tenant_id
  (singletons usam 'singleton'); realtime de tabelas tenant aponta pro schema
  do tenant ativo; repos dropam tenant_id defensivamente de payloads externos
- agendaSelects: tenant_id fora do AGENDA_EVENT_SELECT (quebraria PostgREST)
- zero embeds cross-schema (todos FK embeds sao tenant->tenant ou global->global)
- build de producao passa; 67 .js checados

Pendente (fora do escopo F3, sao cross-tenant/anon -> F4/F6):
- AgendadorPublicoPage (anon, resolve tenant por link_slug)
- Saas{Feriados,NotificationTemplates,DocumentTemplates,Whatsapp}Page
  (gerenciam defaults do sistema / views cross-tenant)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 04:44:59 -03:00

677 lines
30 KiB
JavaScript

/*
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/agenda/composables/useAgendaEventLifecycle.js
| Data: 2026-05-04
|
| A66 sub-sessão 1C-ii-b — lifecycle do AgendaEventDialog:
| - Watcher props.modelValue (init form ao abrir — orquestra
| loadPatients/ensureServicesLoaded/loadInsurancePlans/
| _loadCommitmentItemsForEvent + reset de refs)
| - Watcher [tenantId, restrictPatients, patientScopeOwnerId]
| - Watcher [dia, startTime] (solicitação pendente do agendador público)
| - Watcher [dia, modalidade] (online slots loader)
| - Series pills (loadSerieEvents + 4 handlers + generateRuleDates)
| - selectSlot
| - Quick-creates wiring (service + insurance)
| - onSendManualReminder (lembrete WhatsApp)
|
| Recebe via argumento:
| composer — composer (1B)
| actions — actions (1C-i): _skipStatusWatch, _restoringConvenio,
| samePatientConflict
| pickerBilling — picker/billing (1C-ii-a): ensureServicesLoaded,
| _loadCommitmentItemsForEvent, clearPatientsCache,
| loadPatients, addItem
| commitmentItems — ref<Item[]>
| serieEvents — ref<SerieEvent[]>
| servicePickerSel — ref do picker
| selectedPlanService — ref do procedure de convênio
| serieValorMode — ref<'multiplicar' | 'dividir'>
| services — ref<Service[]> (de useServices)
| loadServices — fn(ownerId)
| loadInsurancePlans — fn(ownerId)
| props — props do dialog
| emit — emitter ('updateSeriesEvent', 'editSeriesOccurrence', 'delete')
| confirm — useConfirm()
| toast — useToast()
|--------------------------------------------------------------------------
*/
import { ref, computed, watch, nextTick } from 'vue';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
export function generateRuleDates(rule) {
const { type, interval = 1, weekdays = [], start_date, end_date, max_occurrences } = rule || {};
if (!start_date || !weekdays?.length) return [];
const maxOcc = Math.min(max_occurrences || 365, 365);
const endLimit = end_date ? new Date(end_date + 'T23:59:59') : null;
const dates = [];
if (type === 'custom_weekdays') {
const cursor = new Date(start_date + 'T12:00:00');
let safety = 0;
while (dates.length < maxOcc && safety < 2000) {
safety++;
if (endLimit && cursor > endLimit) break;
if (weekdays.includes(cursor.getDay())) dates.push(cursor.toISOString().slice(0, 10));
cursor.setDate(cursor.getDate() + 1);
}
} else {
// weekly (interval=1) ou quinzenal (interval=2)
const cursor = new Date(start_date + 'T12:00:00');
while (dates.length < maxOcc) {
if (endLimit && cursor > endLimit) break;
dates.push(cursor.toISOString().slice(0, 10));
cursor.setDate(cursor.getDate() + 7 * (interval || 1));
}
}
return dates;
}
export function useAgendaEventLifecycle({
composer,
actions,
pickerBilling,
commitmentItems,
serieEvents,
servicePickerSel,
selectedPlanService,
serieValorMode,
services,
loadServices,
loadInsurancePlans,
props,
emit,
confirm,
toast
}) {
// ── refs locais ────────────────────────────────────────────
const solicitacaoPendente = ref(null);
const onlineSlots = ref([]);
const loadingOnlineSlots = ref(false);
const serieLoading = ref(false);
const pillDeleteMenuRef = ref(null);
const pillDeleteTarget = ref(null);
const sendingReminder = ref(false);
const serviceQuickDlgOpen = ref(false);
const insuranceQuickDlgOpen = ref(false);
const planServiceQuickDlgOpen = ref(false);
// occurrenceMode: financial_record da ocorrencia atual (se existir).
// Usado pra travar edicao de tipo/servicos quando ja ha cobranca emitida
// (padrao SimplePractice — cobranca emitida e imutavel; ajustes via fluxo
// do Financeiro, nao via dialog). 2026-05-12.
const occFinancialRecord = ref(null);
const occFinancialLoading = ref(false);
// sessionPaymentRecord (2026-05-18): financial_record da sessão (mesmo
// shape do occFinancialRecord) mas SEM o guard de occurrenceMode.
// Carregado em qualquer edit de sessão pra alimentar a linha "Cobrança"
// do Resumo lateral do AgendaEventDialog. Não dispara lock — esse
// continua via occFinancialRecord (território da Fase 6/C13).
const sessionPaymentRecord = ref(null);
// sessionContract (2026-05-19 noite): billing_contract ativo do paciente
// quando a sessão pertence a uma série com pacote (upfront ou saldo).
// Usado pra exibir info card no dialog explicando o pacote (qtd
// sessões usadas/restantes, valor, comportamento). Pra saldo, é a
// única forma do user entender o que tá acontecendo (nada aparece
// no /financeiro até realizar sessão).
const sessionContract = ref(null);
// ── computeds locais ───────────────────────────────────────
const serieCountByStatus = computed(() => {
const counts = {};
for (const ev of serieEvents.value) {
const s = ev._status || 'agendado';
counts[s] = (counts[s] || 0) + 1;
}
return counts;
});
const pillDeleteMenuItems = computed(() => {
if (!pillDeleteTarget.value) return [];
const ev = pillDeleteTarget.value;
return [
{ label: 'Remover apenas esta', icon: 'pi pi-minus-circle', command: () => onPillDelete(ev, 'somente_este') },
{ label: 'Remover esta e as seguintes', icon: 'pi pi-forward', command: () => onPillDelete(ev, 'este_e_seguintes') },
{ separator: true },
{ label: 'Remover todas as futuras', icon: 'pi pi-trash', command: () => onPillDelete(ev, 'todos') }
];
});
// ── series pills ───────────────────────────────────────────
async function loadSerieEvents() {
const rid = props.eventRow?.recurrence_id ?? props.eventRow?.serie_id ?? null;
if (!rid) {
serieEvents.value = [];
return;
}
serieLoading.value = true;
try {
const { data: rule, error: ruleErr } = await tenantDb().from('recurrence_rules').select('*').eq('id', rid).maybeSingle();
if (ruleErr) throw ruleErr;
const { data: excData } = await tenantDb().from('recurrence_exceptions').select('original_date, type, reason').eq('recurrence_id', rid);
const exMap = new Map((excData || []).map((e) => [e.original_date, e]));
const { data: realData } = await tenantDb().from('agenda_eventos')
.select('id, inicio_em, fim_em, status, recurrence_date')
.eq('recurrence_id', rid)
.is('mirror_of_event_id', null)
.order('inicio_em', { ascending: true });
const realMap = new Map((realData || []).map((e) => [e.recurrence_date || e.inicio_em?.slice(0, 10), e]));
const dates = rule ? generateRuleDates(rule) : [];
const startTime = rule?.start_time || '00:00:00';
const durMin = rule?.duration_min || 50;
const list = dates.map((dateISO) => {
const real = realMap.get(dateISO);
const exc = exMap.get(dateISO);
const isCancelled = exc?.type === 'cancel_session' || exc?.type === 'holiday_block';
const inicioStr = real?.inicio_em || `${dateISO}T${startTime}`;
const fimDate = new Date(`${dateISO}T${startTime}`);
fimDate.setMinutes(fimDate.getMinutes() + durMin);
const fimStr = real?.fim_em || fimDate.toISOString();
return {
id: real?.id || null,
inicio_em: inicioStr,
fim_em: fimStr,
status: real?.status || (isCancelled ? 'cancelado' : 'agendado'),
recurrence_date: dateISO,
_status: real?.status || (isCancelled ? 'cancelado' : 'agendado'),
_is_virtual: !real?.id,
_cancelled: isCancelled,
_reason: exc?.reason || null
};
});
for (const [dateISO, real] of realMap) {
if (!dates.includes(dateISO)) {
list.push({
id: real.id,
inicio_em: real.inicio_em,
fim_em: real.fim_em,
status: real.status || 'agendado',
recurrence_date: dateISO,
_status: real.status || 'agendado',
_is_virtual: false,
_cancelled: false,
_reason: null
});
}
}
list.sort((a, b) => new Date(a.inicio_em) - new Date(b.inicio_em));
serieEvents.value = list;
} catch (e) {
console.error('[serie] erro ao carregar:', e);
serieEvents.value = [];
} finally {
serieLoading.value = false;
}
}
// ── occurrence financial record loader ────────────────────
// Tenta 3 caminhos pra encontrar um record relevante:
// 1) Record direto por agenda_evento_id (materializada com cobrança própria)
// 2) Se a sessão pertence a contrato upfront PAGO, sintetiza um record
// 'paid' com o package_price → assim TODAS as ocorrências da série
// mostram "Cobrança paga" no dialog (cobertas pelo pacote pago).
// 3) Senão, null → card unlocked.
async function loadOccFinancialRecord() {
occFinancialRecord.value = null;
// Guard de occurrenceMode REMOVIDO em 2026-05-19 — necessario pra
// que o lock de "edit cobrada" (Fase 6) tambem ative em Melissa,
// nao so em Rail/Clinica. Padrao SimplePractice: cobranca emitida
// eh imutavel pelo dialog; ajustes via estorno no Financeiro.
const evId = props.eventRow?.id;
const ruleId = props.eventRow?.recurrence_id;
const patientId = props.eventRow?.paciente_id || props.eventRow?.patient_id;
occFinancialLoading.value = true;
try {
// 1) Record direto (materializada que tem agenda_evento_id real)
const isVirtualId = typeof evId === 'string' && evId.startsWith('rec::');
if (evId && !isVirtualId) {
const { data, error } = await tenantDb().from('financial_records')
.select('id, amount, final_amount, status, due_date, paid_at, payment_method')
.eq('agenda_evento_id', evId)
.in('status', ['pending', 'paid', 'overdue'])
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (error) throw error;
if (data) {
occFinancialRecord.value = data;
return;
}
}
// 2) Sintetiza record 'paid' quando há contrato upfront pago.
// A 1ª materializada tem o record real; siblings (virtuais ou
// materializadas sem cobrança individual) herdam status do
// contrato pra UI mostrar "Cobrança paga" coerentemente.
if (ruleId && patientId) {
const { data: contracts } = await tenantDb().from('billing_contracts')
.select('id, package_price, charging_style, status')
.eq('patient_id', patientId)
.eq('type', 'package')
.eq('status', 'active')
.order('created_at', { ascending: false });
const upfront = (contracts || []).find((c) => c.charging_style === 'upfront');
if (upfront) {
// Confere se há record PAGO ligado a qualquer evento do
// mesmo recurrence_id (ou seja, contrato foi quitado).
const { data: siblingEvents } = await tenantDb().from('agenda_eventos')
.select('id')
.eq('recurrence_id', ruleId);
const ids = (siblingEvents || []).map((e) => e.id);
if (ids.length) {
// Pega o record mais recente NAO cancelado (paid OU
// pending OU overdue). Pacote upfront tem 1 record
// unico cobrindo toda a serie — qualquer status dele
// trava as siblings (cobranca ja emitida, imutavel).
const { data: anyRec } = await tenantDb().from('financial_records')
.select('id, amount, final_amount, status, due_date, paid_at, payment_method')
.in('agenda_evento_id', ids)
.in('status', ['paid', 'pending', 'overdue'])
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (anyRec) {
// Sintetiza usando o package_price (não o per-session
// que pode ter mudado depois de editar).
occFinancialRecord.value = {
...anyRec,
amount: upfront.package_price,
final_amount: upfront.package_price,
_synthesized: true
};
return;
}
}
}
}
occFinancialRecord.value = null;
} catch (e) {
console.warn('[occurrence] erro ao carregar financial_record:', e?.message);
occFinancialRecord.value = null;
} finally {
occFinancialLoading.value = false;
}
}
// sessionPaymentRecord loader (2026-05-18): mesma query, sem guard
// de occurrenceMode. Alimenta a linha "Cobrança" do Resumo do dialog
// em qualquer edit de sessão (Melissa/Rail/Clínica) com eventRow.id.
async function loadSessionPaymentRecord() {
sessionPaymentRecord.value = null;
const evId = props.eventRow?.id;
if (!evId) return;
try {
const { data, error } = await tenantDb().from('financial_records')
.select('id, amount, final_amount, status, due_date, paid_at, payment_method')
.eq('agenda_evento_id', evId)
.in('status', ['pending', 'paid', 'overdue'])
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (error) throw error;
sessionPaymentRecord.value = data ?? null;
} catch (e) {
console.warn('[session-payment] erro ao carregar financial_record:', e?.message);
sessionPaymentRecord.value = null;
}
}
// loadSessionContract (2026-05-19 noite): busca billing_contract ativo
// do paciente quando o evento pertence a uma série com pacote. Usado
// pra info card no dialog explicando o pacote saldo/upfront.
async function loadSessionContract() {
sessionContract.value = null;
const patientId = props.eventRow?.paciente_id || props.eventRow?.patient_id;
const ruleId = props.eventRow?.recurrence_id;
// Só faz sentido pra sessão de série
if (!patientId || !ruleId) return;
try {
const { data, error } = await tenantDb().from('billing_contracts')
.select('id, type, total_sessions, sessions_used, package_price, charging_style, status, active_from')
.eq('patient_id', patientId)
.eq('type', 'package')
.eq('status', 'active')
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (error) throw error;
sessionContract.value = data ?? null;
} catch (e) {
console.warn('[session-contract] erro ao carregar:', e?.message);
sessionContract.value = null;
}
}
function onPillEditClick(ev) {
emit('editSeriesOccurrence', {
id: ev.id,
recurrence_date: ev.recurrence_date,
inicio_em: ev.inicio_em,
fim_em: ev.fim_em,
is_virtual: ev._is_virtual
});
}
function onPillStatusChange(ev) {
emit('updateSeriesEvent', {
id: ev.id,
status: ev._status,
recurrence_date: ev.recurrence_date,
inicio_em: ev.inicio_em,
fim_em: ev.fim_em,
is_virtual: ev._is_virtual
});
if (ev._is_virtual) {
setTimeout(() => loadSerieEvents(), 700);
}
}
function onPillDeleteClick(ev, event) {
pillDeleteTarget.value = ev;
nextTick(() => pillDeleteMenuRef.value?.toggle(event));
}
function onPillDelete(ev, mode) {
const isTodos = mode === 'todos';
confirm.require({
header: isTodos ? 'Encerrar toda a série' : 'Excluir sessão recorrente',
message: isTodos
? 'Todos os agendamentos futuros da série serão removidos permanentemente. Esta sessão será mantida como avulsa. Esta ação é irreversível.'
: mode === 'este_e_seguintes'
? 'Esta sessão e todas as seguintes serão removidas. Tem certeza?'
: 'Esta sessão será cancelada. Tem certeza?',
icon: isTodos ? 'pi pi-trash' : 'pi pi-exclamation-triangle',
acceptClass: 'p-button-danger',
acceptLabel: isTodos ? 'Sim, encerrar série' : 'Confirmar',
rejectLabel: 'Cancelar',
accept: () =>
emit('delete', {
id: ev.id,
editMode: mode,
recurrence_id: props.eventRow?.recurrence_id ?? props.eventRow?.serie_id ?? null,
original_date: ev.recurrence_date || (ev.inicio_em ? ev.inicio_em.slice(0, 10) : null),
serie_id: props.eventRow?.serie_id ?? null
})
});
}
// ── slot selection ─────────────────────────────────────────
function selectSlot(hhmm) {
const [h, m] = String(hhmm).split(':').map(Number);
const d = new Date();
d.setHours(h, m, 0, 0);
composer.startTimeDate.value = d;
}
// ── quick-creates ──────────────────────────────────────────
function openServiceQuickCreate() {
serviceQuickDlgOpen.value = true;
}
async function onServiceCreated(svc) {
await loadServices(props.ownerId);
if (svc?.id) {
const list = services?.value;
const fresh = (Array.isArray(list) ? list.find((s) => s.id === svc.id) : null) || svc;
if (typeof pickerBilling.addItem === 'function') {
pickerBilling.addItem(fresh);
}
}
}
function openInsuranceQuickCreate() {
insuranceQuickDlgOpen.value = true;
}
async function onInsuranceCreated(plan) {
await loadInsurancePlans(props.planOwnerId || props.ownerId);
if (plan?.id) {
composer.form.value.insurance_plan_id = plan.id;
}
}
// Quick-create de procedimento (insurance_plan_services) — inline,
// sem sair do dialog. Trigger no card Sessao/Honorarios quando o
// convenio selecionado nao tem procedimentos ou quando user quer
// adicionar mais. Apos criar, recarrega os planos pra refletir no
// computed planServices.
function openPlanServiceQuickCreate() {
if (!composer.form.value.insurance_plan_id) return;
planServiceQuickDlgOpen.value = true;
}
async function onPlanServiceCreated(service) {
await loadInsurancePlans(props.planOwnerId || props.ownerId);
// Auto-seleciona o procedimento recem-criado se o user nao
// tinha nenhum selecionado ainda (caso comum: convenio sem
// procedimentos -> cadastra o primeiro -> ja entra selecionado).
if (service?.id && !pickerBilling.selectedPlanService.value) {
pickerBilling.selectedPlanService.value = service.id;
pickerBilling.onProcedureSelect(service.id);
}
}
// ── lembrete WhatsApp manual (8.2) ─────────────────────────
async function onSendManualReminder() {
if (!composer.form.value?.id) return;
confirm.require({
header: 'Enviar lembrete WhatsApp?',
message: `Vou mandar o template "lembrete_sessao" pra ${composer.form.value.paciente_nome || 'o paciente'} agora. Pode disparar?`,
icon: 'pi pi-whatsapp',
acceptLabel: 'Enviar',
rejectLabel: 'Cancelar',
accept: async () => {
sendingReminder.value = true;
try {
const { data, error } = await supabase.functions.invoke('send-session-reminder-manual', {
body: { event_id: composer.form.value.id }
});
if (error || !data?.ok) {
const err = data?.error || error?.message || 'unknown_error';
let friendly = err;
if (err === 'no_phone') friendly = 'Paciente sem telefone cadastrado.';
else if (err === 'invalid_phone') friendly = 'Telefone do paciente inválido.';
else if (err === 'no_active_channel') friendly = 'Nenhum canal WhatsApp ativo. Configure em Configurações → WhatsApp.';
else if (err === 'template_not_found') friendly = 'Template "lembrete_sessao" não encontrado. Configure em Configurações → WhatsApp.';
else if (err === 'forbidden') friendly = 'Você não tem permissão pra enviar por este canal.';
else if (String(err).startsWith('send_failed')) friendly = 'Não conseguimos enviar. Verifique a conexão do WhatsApp.';
throw new Error(friendly);
}
toast.add({ severity: 'success', summary: 'Lembrete enviado', detail: data.to ? `Para ${data.to}` : undefined, life: 3500 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao enviar lembrete', detail: e.message, life: 5000 });
} finally {
sendingReminder.value = false;
}
}
});
}
// ── watchers ───────────────────────────────────────────────
// Init form ao abrir o dialog (orquestra tudo)
watch(
() => props.modelValue,
async (open) => {
if (!open) return;
await nextTick();
actions._skipStatusWatch.value = true;
composer.form.value = composer.resetForm();
await nextTick();
actions._skipStatusWatch.value = false;
actions.samePatientConflict.value = null;
composer.recorrenciaType.value = 'avulsa';
composer.diasSelecionados.value = [];
composer.dataLimiteManual.value = null;
composer.qtdSessoesMode.value = '4';
composer.qtdSessoesCustom.value = 12;
composer.editScope.value = 'somente_este';
if (serieValorMode) serieValorMode.value = 'multiplicar';
if (composer.isEdit.value && composer.form.value.paciente_id && !composer.form.value.paciente_nome) {
tenantDb().from('patients')
.select('id, nome_completo')
.eq('id', composer.form.value.paciente_id)
.maybeSingle()
.then(({ data }) => {
if (data?.nome_completo) composer.form.value.paciente_nome = data.nome_completo;
});
}
if (composer.hasSerie.value) loadSerieEvents();
else serieEvents.value = [];
// occurrenceMode: carrega financial_record desta ocorrencia
// pra decidir se o card Sessao/Honorarios fica locked (cobranca
// ja emitida) ou unlocked (sem cobranca, edicao livre).
loadOccFinancialRecord();
// sessionPaymentRecord: carrega em qualquer edit (Melissa
// tambem) pra alimentar a linha "Cobrança" do Resumo lateral.
loadSessionPaymentRecord();
loadSessionContract();
// occurrenceMode: editando UMA ocorrencia de serie ja existente —
// tipo de compromisso ja foi escolhido (paciente + sessao). Pular
// step 1 incondicionalmente. Defesa em camadas: useMelissaAgenda
// ja seta is_occurrence=true na row (faz isEdit=true), mas se outro
// call site esquecer essa flag o guard aqui salva.
if (props.occurrenceMode || composer.isEdit.value) {
composer.step.value = 2;
} else {
const preset = props.presetCommitmentId;
if (preset) {
composer.form.value.commitment_id = preset;
composer.step.value = 2;
} else composer.step.value = 1;
}
pickerBilling.clearPatientsCache();
if (composer.requiresPatient.value) pickerBilling.loadPatients(true);
pickerBilling.ensureServicesLoaded();
const insuranceOwner = props.planOwnerId || props.ownerId;
if (insuranceOwner) {
await loadInsurancePlans(insuranceOwner);
}
selectedPlanService.value = null;
actions._restoringConvenio.value = false;
commitmentItems.value = [];
servicePickerSel.value = null;
if (composer.isEdit.value && (composer.form.value.id || props.eventRow?.recurrence_id)) {
pickerBilling._loadCommitmentItemsForEvent(composer.form.value.id);
} else {
composer.billingType.value = 'particular';
}
}
);
// Tenant/scope mudou — recarrega lista de pacientes
watch(
() => [props.tenantId, props.restrictPatientsToOwner, props.patientScopeOwnerId],
() => {
if (!composer.visible.value) return;
pickerBilling.clearPatientsCache();
if (composer.requiresPatient.value) pickerBilling.loadPatients(true);
}
);
// Solicitação pendente do agendador público no horário escolhido
watch(
() => [composer.form.value.dia?.toString(), composer.form.value.startTime],
async ([dia, startTime]) => {
solicitacaoPendente.value = null;
if (!composer.isSessionEvent.value || !composer.visible.value || composer.isEdit.value) return;
if (!props.ownerId || !dia || !startTime) return;
const d = new Date(composer.form.value.dia);
const isoDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const { data } = await tenantDb().from('agendador_solicitacoes')
.select('id, paciente_nome, paciente_sobrenome, paciente_email')
.eq('owner_id', props.ownerId)
.eq('status', 'pendente')
.eq('data_solicitada', isoDate)
.eq('hora_solicitada', startTime)
.maybeSingle();
solicitacaoPendente.value = data || null;
}
);
// Online slots: depende de [dia, modalidade]
watch(
[() => composer.form.value.dia, () => composer.form.value.modalidade],
async ([dia, mod]) => {
if (mod !== 'online' || !dia || !props.ownerId) {
onlineSlots.value = [];
return;
}
const dow = new Date(dia).getDay();
loadingOnlineSlots.value = true;
try {
const { data } = await tenantDb().from('agenda_online_slots')
.select('time')
.eq('owner_id', props.ownerId)
.eq('weekday', dow)
.eq('enabled', true)
.order('time');
onlineSlots.value = (data || []).map((s) => ({ hhmm: String(s.time || '').slice(0, 5) }));
} catch {
onlineSlots.value = [];
} finally {
loadingOnlineSlots.value = false;
}
},
{ immediate: true }
);
return {
// refs
solicitacaoPendente,
onlineSlots,
loadingOnlineSlots,
serieLoading,
pillDeleteMenuRef,
pillDeleteTarget,
sendingReminder,
serviceQuickDlgOpen,
insuranceQuickDlgOpen,
planServiceQuickDlgOpen,
occFinancialRecord,
occFinancialLoading,
sessionPaymentRecord,
// computeds
serieCountByStatus,
pillDeleteMenuItems,
// series
loadSerieEvents,
loadOccFinancialRecord,
loadSessionPaymentRecord,
sessionContract,
loadSessionContract,
onPillEditClick,
onPillStatusChange,
onPillDeleteClick,
onPillDelete,
// slot
selectSlot,
// quick-creates
openServiceQuickCreate,
onServiceCreated,
openInsuranceQuickCreate,
onInsuranceCreated,
openPlanServiceQuickCreate,
onPlanServiceCreated,
// reminder
onSendManualReminder
};
}