diff --git a/src/features/agenda/components/AgendaEventDialog.vue b/src/features/agenda/components/AgendaEventDialog.vue index f8854ea..5f08a50 100644 --- a/src/features/agenda/components/AgendaEventDialog.vue +++ b/src/features/agenda/components/AgendaEventDialog.vue @@ -22,28 +22,60 @@ import Select from 'primevue/select'; import Textarea from 'primevue/textarea'; import DatePicker from 'primevue/datepicker'; import InputNumber from 'primevue/inputnumber'; -import RadioButton from 'primevue/radiobutton'; +// RadioButton removido (auto-import via PrimeVueResolver caso necessário no template) import Message from 'primevue/message'; import { useConfirm } from 'primevue/useconfirm'; import { useToast } from 'primevue/usetoast'; import { supabase } from '@/lib/supabase/client'; import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue'; import AgendaEventoFinanceiroPanel from '@/components/agenda/AgendaEventoFinanceiroPanel.vue'; +import ServiceQuickCreateDialog from './ServiceQuickCreateDialog.vue'; +import InsurancePlanQuickCreateDialog from './InsurancePlanQuickCreateDialog.vue'; import { useServices } from '@/features/agenda/composables/useServices'; import { useCommitmentServices } from '@/features/agenda/composables/useCommitmentServices'; import { usePatientDiscounts } from '@/features/agenda/composables/usePatientDiscounts'; import { useInsurancePlans } from '@/features/agenda/composables/useInsurancePlans'; -import { getPatientAgendaPermissions } from '@/composables/usePatientLifecycle'; +// getPatientAgendaPermissions agora é importado dentro do composer (1B) -function patientInitials(nome) { - const parts = String(nome || '') - .trim() - .split(/\s+/) - .filter(Boolean); - if (!parts.length) return '?'; - if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase(); - return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase(); -} +// A66 sub-sessão 1A — helpers PUROS extraídos pra módulo isolado e +// testável. Antes viviam inline neste arquivo (24 funções, ~150 linhas). +// Agora vem de um único import; lógica idêntica, só repousada. +import { + patientInitials, + fmtBRL, + fmtJornadaHora, + fmtDateBR, + fmtDateBRLong, + fmtTime, + fmtDuracao, + nomeDiaSemana, + fmtWeekdayShort, + fmtDayNum, + fmtMonthShort, + hhmmToMin, + minToHHMM, + isPast, + isNativeSession, + calcFinalPrice, + labelStatusSessao, + statusSeverity, + statusExtraClass +} from '@/features/agenda/composables/agendaEventHelpers'; + +// A66 sub-sessão 1B — composable factory com state + computeds derivados. +import { useAgendaEventComposer } from '@/features/agenda/composables/useAgendaEventComposer'; + +// A66 sub-sessão 1C-i — watchers + handlers de save/delete. +import { useAgendaEventActions } from '@/features/agenda/composables/useAgendaEventActions'; + +// A66 sub-sessão 1C-ii-a — patient picker + billing items + 2 watchers +// (form.commitment_id auto-fill price, form.insurance_plan_id limpa items). +import { useAgendaEventPickerBilling } from '@/features/agenda/composables/useAgendaEventPickerBilling'; + +// A66 sub-sessão 1C-ii-b — lifecycle: 4 watchers (modelValue init, tenant +// scope, solicitação pendente, online slots) + series pills + selectSlot +// + quick-creates wiring + onSendManualReminder. +import { useAgendaEventLifecycle } from '@/features/agenda/composables/useAgendaEventLifecycle'; const props = defineProps({ modelValue: { type: Boolean, default: false }, @@ -82,48 +114,69 @@ const toast = useToast(); const router = useRouter(); const route = useRoute(); -const visible = computed({ - get: () => props.modelValue, - set: (v) => emit('update:modelValue', v) -}); +// ────────────────────────────────────────────────────────────────── +// A66 sub-sessão 1B — state + computeds derivados extraídos pra +// composable factory (testado isoladamente em useAgendaEventComposer.spec). +// Refs externos (commitmentItems, serieEvents) declarados aqui antes +// pra serem passados via extras — o composer usa em canSave e isFirstOccurrence. +// ────────────────────────────────────────────────────────────────── +const commitmentItems = ref([]); +const serieEvents = ref([]); -const step = ref(1); -const isEdit = computed(() => !!props.eventRow?.id || !!props.eventRow?.is_occurrence); - -const allowBack = computed(() => !props.lockCommitment && !props.presetCommitmentId); - -// ── série ───────────────────────────────────────────────── -const hasSerie = computed(() => !!(props.eventRow?.recurrence_id || props.eventRow?.serie_id || props.eventRow?.is_occurrence)); - -const currentRecurrenceDate = computed(() => props.eventRow?.recurrence_date || props.eventRow?.inicio_em?.slice(0, 10) || null); - -const editScope = ref('somente_este'); - -const isFirstOccurrence = computed(() => { - if (!hasSerie.value) return false; - const rDate = props.eventRow?.recurrence_date || props.eventRow?.original_date; - if (!rDate) return false; - if (serieEvents.value?.length) { - const dates = serieEvents.value - .map((e) => e.recurrence_date || e.original_date) - .filter(Boolean) - .sort(); - return dates[0] === rDate; - } - return false; -}); - -const editScopeOptions = computed(() => [ - { value: 'somente_este', label: 'Somente esta sessão' }, - { value: 'este_e_seguintes', label: 'Esta e as seguintes', disabled: isFirstOccurrence.value }, - { value: 'todos', label: 'Todas da série' }, - { value: 'todos_sem_excecao', label: 'Todas sem exceção' } -]); - -// ── recorrência (criação / sessão avulsa) ────────────────── -// 'avulsa' | 'semanal' | 'quinzenal' | 'diasEspecificos' -const recorrenciaType = ref('avulsa'); +const _composer = useAgendaEventComposer(props, emit, { commitmentItems, serieEvents }); +const { + visible, + step, + editScope, + recorrenciaType, + diasSelecionados, + qtdSessoesMode, + qtdSessoesCustom, + dataLimiteManual, + billingType, + form, + isEdit, + allowBack, + hasSerie, + currentRecurrenceDate, + isFirstOccurrence, + editScopeOptions, + qtdSessoesEfetiva, + diaSemanaRecorrencia, + proximasOcorrencias, + dataFimCalculada, + totalOcorrencias, + sessoesForaDoPlano, + ocorrenciasComConflito, + totalConflitos, + commitmentCards, + selectedCommitment, + selectedCommitmentName, + selectedCommitmentFields, + requiresPatient, + isSessionEvent, + patientLocked, + hasInsurance, + agendaPerms, + isSessionFuture, + isArchivedPastEdit, + isInativoFutureEdit, + statusOptionsFiltered, + startTimeDate, + inicioDateTime, + fimDateTime, + dataHoraDisplay, + previewRange, + computedTitulo, + headerTitle, + canSave, + timeConflict, + toggleDiaSelecionado, + isForaDoPlano, + resetForm +} = _composer; +// ── recorrência: opções estáticas (consts data — não migradas) ── const freqOpcoes = [ { value: 'avulsa', label: 'Avulsa' }, { value: 'semanal', label: 'Semanal' }, @@ -140,190 +193,30 @@ const diasSemanaOpcoes = [ { value: 6, short: 'Sáb' }, { value: 0, short: 'Dom' } ]; -const diasSelecionados = ref([]); - -function toggleDiaSelecionado(dow) { - const idx = diasSelecionados.value.indexOf(dow); - if (idx === -1) diasSelecionados.value.push(dow); - else diasSelecionados.value.splice(idx, 1); -} function setHoje() { form.value.dia = new Date(); } -// Seletor de quantidade -const qtdSessoesMode = ref('4'); -const qtdSessoesCustom = ref(12); - const qtdSessoesOpcoes = [ { value: '4', label: '4 sessões' }, { value: '8', label: '8 sessões' }, { value: '12', label: '12 sessões' }, { value: 'personalizar', label: 'Personalizar' } ]; +// recorrenciaType, diasSelecionados, toggleDiaSelecionado, qtdSessoesMode, +// qtdSessoesCustom, qtdSessoesEfetiva, diaSemanaRecorrencia → composer (1B) -const qtdSessoesEfetiva = computed(() => { - if (qtdSessoesMode.value === '4') return 4; - if (qtdSessoesMode.value === '8') return 8; - if (qtdSessoesMode.value === '12') return 12; - return Math.max(1, Number(qtdSessoesCustom.value || 1)); -}); +// proximasOcorrencias, dataFimCalculada, totalOcorrencias, dataLimiteManual, +// isForaDoPlano, sessoesForaDoPlano, conflictForDate, ocorrenciasComConflito, +// totalConflitos, commitmentCards, form (+ resetForm) → composer (1B) +// isNativeSession → agendaEventHelpers (1A) -const diaSemanaRecorrencia = computed(() => { - const d = form.value.dia ? new Date(form.value.dia) : new Date(); - return d.getDay(); -}); - -const proximasOcorrencias = computed(() => { - if (recorrenciaType.value === 'avulsa' || !form.value.dia) return []; - const result = []; - const total = qtdSessoesEfetiva.value; - - if (recorrenciaType.value === 'semanal' || recorrenciaType.value === 'quinzenal') { - const step = recorrenciaType.value === 'quinzenal' ? 14 : 7; - const cursor = new Date(form.value.dia); - while (result.length < total) { - result.push(new Date(cursor)); - cursor.setDate(cursor.getDate() + step); - } - } else if (recorrenciaType.value === 'diasEspecificos') { - if (!diasSelecionados.value.length) return []; - const sorted = [...diasSelecionados.value].sort((a, b) => a - b); - const start = new Date(form.value.dia); - const cur = new Date(start); - let safety = 0; - while (result.length < total && safety < 1000) { - if (sorted.includes(cur.getDay()) && cur >= start) result.push(new Date(cur)); - cur.setDate(cur.getDate() + 1); - safety++; - } - } - return result; -}); - -const dataFimCalculada = computed(() => { - const oc = proximasOcorrencias.value; - return oc.length ? oc[oc.length - 1] : null; -}); - -const totalOcorrencias = computed(() => proximasOcorrencias.value.length); - -const dataLimiteManual = ref(null); -function isForaDoPlano(d) { - if (!dataLimiteManual.value) return false; - return new Date(d) > new Date(dataLimiteManual.value); -} -const sessoesForaDoPlano = computed(() => proximasOcorrencias.value.filter((d) => isForaDoPlano(d)).length); - -function _conflictForDate(date) { - if (!date) return null; - const dow = date.getDay(); - const iso = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; - - // 1. Folga - if (props.workRules?.length && !props.workRules.some((r) => Number(r.dia_semana) === dow)) { - return { type: 'folga', label: 'dia de folga' }; - } - - // 2. Feriado - const feriado = (props.feriados || []).find((f) => { - const fiso = f.date || f.data || f.iso || ''; - return String(fiso).slice(0, 10) === iso; - }); - if (feriado) return { type: 'feriado', label: `feriado: ${feriado.name || feriado.nome || ''}` }; - - // 3. Bloqueio manual (blockedDates já são ISO strings YYYY-MM-DD) - if ((props.blockedDates || []).includes(iso)) { - return { type: 'bloqueado', label: 'dia bloqueado' }; - } - - // 4. Pausa (checa se o horário da sessão sobrepõe alguma pausa do dia) - if (form.value.startTime) { - const [sh, sm] = form.value.startTime.split(':').map(Number); - const slotS = sh * 60 + sm; - const slotE = slotS + (form.value.duracaoMin || 50); - const pausas = (props.pausasSemanais || []).filter((p) => p.dia_semana == null || Number(p.dia_semana) === dow); - for (const p of pausas) { - const [ph, pm] = String(p.hora_inicio || '00:00') - .split(':') - .map(Number); - const [eh, em] = String(p.hora_fim || '00:00') - .split(':') - .map(Number); - if (slotS < eh * 60 + em && slotE > ph * 60 + pm) { - return { type: 'pausa', label: 'horário de pausa' }; - } - } - } - - return null; -} - -const ocorrenciasComConflito = computed(() => - proximasOcorrencias.value.map((d) => ({ - date: d, - conflict: _conflictForDate(d) - })) -); - -const totalConflitos = computed(() => ocorrenciasComConflito.value.filter((o) => o.conflict).length); - -// ── commitments ──────────────────────────────────────────── -const commitmentCards = computed(() => { - const list = Array.isArray(props.commitmentOptions) ? props.commitmentOptions : []; - const prio = new Map([['session', 0]]); - return [...list].sort((a, b) => { - const pa = prio.has(a.native_key) ? prio.get(a.native_key) : 99; - const pb = prio.has(b.native_key) ? prio.get(b.native_key) : 99; - if (pa !== pb) return pa - pb; - return String(a.name || '').localeCompare(String(b.name || ''), 'pt-BR'); - }); -}); - -function isNativeSession(c) { - return String(c?.native_key || '').toLowerCase() === 'session'; -} - -const form = ref(resetForm()); - -// ── ConfirmDialog para status sensíveis (cancelado / remarcar) ──────────── -const _prevStatus = ref(null); -const _skipStatusWatch = ref(false); -watch( - () => form.value?.status, - async (newVal, oldVal) => { - if (_skipStatusWatch.value) return; - if (!isEdit.value || !form.value?.id) return; - if (newVal !== 'cancelado' && newVal !== 'remarcado') return; - - _prevStatus.value = oldVal; - - const isCancelar = newVal === 'cancelado'; - confirm.require({ - header: isCancelar ? 'Cancelar sessão' : 'Remarcar sessão', - message: isCancelar ? 'Tem certeza que deseja cancelar esta sessão? O status será salvo imediatamente.' : 'Tem certeza que deseja marcar esta sessão para remarcação? O status será salvo imediatamente.', - icon: isCancelar ? 'pi pi-times-circle' : 'pi pi-refresh', - acceptLabel: 'Sim, confirmar', - rejectLabel: 'Não', - acceptSeverity: isCancelar ? 'danger' : 'warn', - accept: async () => { - try { - const { data, error } = await supabase.from('agenda_eventos').update({ status: newVal }).eq('id', form.value.id).select().single(); - if (error) throw error; - toast.add({ severity: 'success', summary: 'Status atualizado', detail: `Sessão marcada como ${labelStatusSessao(newVal)}.`, life: 3000 }); - emit('updated', data); - } catch (e) { - toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Não foi possível atualizar o status.', life: 4000 }); - form.value.status = _prevStatus.value; - } - }, - reject: () => { - form.value.status = _prevStatus.value; - } - }); - } -); +// Watcher status confirm (cancelado/remarcado), watcher billingType, +// watcher [paciente_id, dia], onSave, onDelete, onEncerrarSerie +// → useAgendaEventActions (1C-i). Refs internos (_skipStatusWatch, +// _prevStatus, _restoringConvenio, samePatientConflict) vêm do composable +// abaixo, declarado depois das dependências (services/insurancePlans/etc). // ── Precificação / Serviços ───────────────────────────────────────── const { services, getDefaultPrice, load: loadServices } = useServices(); @@ -332,65 +225,155 @@ const { loadActive: loadActiveDiscount } = usePatientDiscounts(); const { plans: insurancePlans, load: loadInsurancePlans } = useInsurancePlans(); const selectedPlanService = ref(null); +// Quick-create dialogs (serviceQuickDlgOpen/insuranceQuickDlgOpen) + +// openServiceQuickCreate / onServiceCreated / openInsuranceQuickCreate / +// onInsuranceCreated → useAgendaEventLifecycle (1C-ii-b) + const activePlans = computed(() => insurancePlans.value.filter((p) => p.active !== false)); const planServices = computed(() => { if (!form.value.insurance_plan_id) return []; return (insurancePlans.value.find((p) => p.id === form.value.insurance_plan_id)?.insurance_plan_services || []).filter((s) => s.active); }); -let _servicesLoaded = false; - -async function ensureServicesLoaded() { - if (_servicesLoaded || !props.ownerId) return; - _servicesLoaded = true; - await loadServices(props.ownerId); -} - -function applyDefaultPrice() { - // Pula quando pago: o preço vem dos commitmentItems, não de um default - if (billingType.value === 'particular') return; - // Só auto-preenche se price ainda não foi definido manualmente (ou é novo evento) - if (!isEdit.value) { - const suggested = getDefaultPrice(); - if (suggested != null) form.value.price = suggested; - } -} +// ensureServicesLoaded + applyDefaultPrice → useAgendaEventPickerBilling (1C-ii-a) // ── Itens de serviço (commitment_services) ────────────────────────── -const commitmentItems = ref([]); +// commitmentItems já declarado no topo (passado ao composer via extras) const servicePickerSel = ref(null); +// serieValorMode usado no template + watcher init (1C-ii-b) — declarado +// antes do useAgendaEventLifecycle abaixo. const serieValorMode = ref('multiplicar'); // 'multiplicar' | 'dividir' -const billingType = ref('particular'); // 'gratuito' | 'particular' | 'convenio' -const _restoringConvenio = ref(false); // flag para ignorar watch durante restauração +// isDynamic precisa existir antes do useAgendaEventPickerBilling abaixo +// (passado como dep). Movido pra cá da seção de computeds derivados. +const isDynamic = computed(() => (props.agendaSettings?.slot_mode ?? 'fixed') === 'dynamic'); + +// billingType → composer (1B); watcher → useAgendaEventActions (1C-i) const billingTypeOptions = [ { label: 'Gratuito', value: 'gratuito' }, { label: 'Particular', value: 'particular' }, { label: 'Convênio', value: 'convenio' } ]; -watch(billingType, (val) => { - if (val === 'gratuito') { - commitmentItems.value = []; - form.value.price = 0; - form.value.insurance_plan_id = null; - form.value.insurance_guide_number = null; - form.value.insurance_value = null; - selectedPlanService.value = null; - } - if (val === 'particular') { - form.value.insurance_plan_id = null; - form.value.insurance_guide_number = null; - form.value.insurance_value = null; - selectedPlanService.value = null; - } - if (val === 'convenio') { - commitmentItems.value = []; - servicePickerSel.value = null; - } +// ────────────────────────────────────────────────────────────────── +// A66 sub-sessão 1C-i — watchers + handlers de save/delete via factory. +// Recebe o composer (1B) + refs externos (commitmentItems, servicePickerSel, +// selectedPlanService) + saveCommitmentItems do useCommitmentServices. +// Retorna: refs internos (_skipStatusWatch, _prevStatus, _restoringConvenio, +// samePatientConflict) + handlers (onSave, onDelete, onEncerrarSerie). +// ────────────────────────────────────────────────────────────────── +const _actions = useAgendaEventActions({ + composer: _composer, + commitmentItems, + servicePickerSel, + selectedPlanService, + saveCommitmentItems, + props, + emit }); +const { + _skipStatusWatch, + _prevStatus, + _restoringConvenio, + samePatientConflict, + onSave, + onDelete, + onEncerrarSerie +} = _actions; -const isDynamic = computed(() => (props.agendaSettings?.slot_mode ?? 'fixed') === 'dynamic'); +// ────────────────────────────────────────────────────────────────── +// A66 sub-sessão 1C-ii-a — patient picker + billing items + 2 watchers. +// Recebe composer + actions (pra _restoringConvenio e samePatientConflict) +// + refs externos + composables (services, insurancePlans, discounts). +// ────────────────────────────────────────────────────────────────── +const _pickerBilling = useAgendaEventPickerBilling({ + composer: _composer, + actions: _actions, + commitmentItems, + servicePickerSel, + selectedPlanService, + services, + loadServices, + getDefaultPrice, + planServices, + loadActiveDiscount, + _csLoadItems, + _csLoadItemsOrTemplate, + isDynamic, + props +}); +const { + pacientePickerOpen, + pacienteSearch, + pacientesLoading, + pacientesError, + patients, + cadRapidoOpen, + ensureServicesLoaded, + resetServicesGate, + applyDefaultPrice, + addItem, + removeItem, + onItemChange, + _loadCommitmentItemsForEvent, + onProcedureSelect, + selectCommitment, + goBack, + openPacientePicker, + clearPatientsCache, + loadPatients, + selectPaciente, + clearPaciente, + openCadastroRapido, + abrirCadastroCompleto +} = _pickerBilling; + +// ────────────────────────────────────────────────────────────────── +// A66 sub-sessão 1C-ii-b — lifecycle: 4 watchers (modelValue init, +// tenant/scope, solicitação pendente, online slots) + series pills +// + selectSlot + quick-creates wiring + onSendManualReminder. +// ────────────────────────────────────────────────────────────────── +const _lifecycle = useAgendaEventLifecycle({ + composer: _composer, + actions: _actions, + pickerBilling: _pickerBilling, + commitmentItems, + serieEvents, + servicePickerSel, + selectedPlanService, + serieValorMode, + services, + loadServices, + loadInsurancePlans, + props, + emit, + confirm, + toast +}); +const { + solicitacaoPendente, + onlineSlots, + loadingOnlineSlots, + serieLoading, + pillDeleteMenuRef, + pillDeleteTarget, + sendingReminder, + serviceQuickDlgOpen, + insuranceQuickDlgOpen, + serieCountByStatus, + pillDeleteMenuItems, + loadSerieEvents, + onPillEditClick, + onPillStatusChange, + onPillDeleteClick, + onPillDelete, + selectSlot, + openServiceQuickCreate, + onServiceCreated, + openInsuranceQuickCreate, + onInsuranceCreated, + onSendManualReminder +} = _lifecycle; const totalFromItems = computed(() => commitmentItems.value.reduce((sum, item) => sum + (item.final_price ?? 0), 0)); @@ -427,132 +410,18 @@ watch(dynamicDuration, (dur) => { if (isDynamic.value && dur != null && dur > 0) form.value.duracaoMin = dur; }); -function calcFinalPrice(unit_price, quantity, discount_pct, discount_flat) { - const subtotal = Number(unit_price) * Number(quantity); - const discPct = subtotal * (Number(discount_pct ?? 0) / 100); - const discFlat = Number(discount_flat ?? 0); - return Math.max(0, subtotal - discPct - discFlat); -} +// calcFinalPrice movido pra agendaEventHelpers.js (A66/1A) -async function addItem(svc) { - if (!svc?.id) return; - // Regra: não duplicar — incrementa quantity do item existente - const existing = commitmentItems.value.find((i) => i.service_id === svc.id); - if (existing) { - existing.quantity++; - existing.final_price = calcFinalPrice(existing.unit_price, existing.quantity, existing.discount_pct, existing.discount_flat); - return; - } - const unit_price = Number(svc.price); - const patientId = form.value.patient_id ?? form.value.paciente_id ?? null; - let discount_pct = 0; - let discount_flat = 0; +// addItem, removeItem, onItemChange, _loadCommitmentItemsForEvent +// → useAgendaEventPickerBilling (1C-ii-a) - if (patientId && props.ownerId) { - const discount = await loadActiveDiscount(props.ownerId, patientId); - if (discount) { - discount_pct = Number(discount.discount_pct ?? 0); - discount_flat = Number(discount.discount_flat ?? 0); - } - } +// fmtBRL movido pra agendaEventHelpers.js (A66/1A) - commitmentItems.value.push({ - service_id: svc.id, - service_name: svc.name, - quantity: 1, - unit_price, - discount_pct, - discount_flat, - final_price: calcFinalPrice(unit_price, 1, discount_pct, discount_flat) - }); -} - -function removeItem(index) { - commitmentItems.value.splice(index, 1); - // Quando lista esvazia em modo dynamic, restaura duração padrão - if (commitmentItems.value.length === 0 && isDynamic.value) { - form.value.duracaoMin = props.agendaSettings?.session_duration_min ?? 50; - } -} - -function onItemChange(item) { - item.final_price = calcFinalPrice(item.unit_price, item.quantity, item.discount_pct, item.discount_flat); -} - -async function _loadCommitmentItemsForEvent(eventId) { - const ruleId = props.eventRow?.recurrence_id ?? null; - const isCustomized = props.eventRow?.services_customized ?? false; - // Lê os dados de convênio diretamente do eventRow (form.value pode ter sido limpo por watches) - const origPlanId = props.eventRow?.insurance_plan_id ?? null; - const origGuide = props.eventRow?.insurance_guide_number ?? null; - const origInsValue = props.eventRow?.insurance_value != null ? Number(props.eventRow.insurance_value) : null; - - function applyConvenio() { - const origPsId = props.eventRow?.insurance_plan_service_id ?? null; - _restoringConvenio.value = true; - form.value.insurance_plan_id = origPlanId; - form.value.insurance_guide_number = origGuide; - form.value.insurance_value = origInsValue; - form.value.insurance_plan_service_id = origPsId; - billingType.value = 'convenio'; - nextTick(() => { - if (origPsId && planServices.value.find((s) => s.id === origPsId)) { - selectedPlanService.value = origPsId; - } else { - selectedPlanService.value = null; - } - _restoringConvenio.value = false; - }); - } - - if (!eventId && !ruleId) { - commitmentItems.value = []; - if (origPlanId) applyConvenio(); - else billingType.value = 'particular'; - return; - } - try { - commitmentItems.value = ruleId ? await _csLoadItemsOrTemplate(eventId, ruleId, { allowEmpty: isCustomized }) : await _csLoadItems(eventId); - if (origPlanId) applyConvenio(); - else billingType.value = commitmentItems.value.length > 0 ? 'particular' : 'gratuito'; - } catch (e) { - console.warn('[AgendaEventDialog] commitment_services load error:', e?.message); - commitmentItems.value = []; - if (origPlanId) applyConvenio(); - else billingType.value = 'gratuito'; - } -} - -function fmtBRL(v) { - if (v == null) return '—'; - return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); -} - -const selectedCommitment = computed(() => { - const id = form.value.commitment_id; - if (!id) return null; - return commitmentCards.value.find((x) => x.id === id) || null; -}); -const selectedCommitmentName = computed(() => selectedCommitment.value?.name || '—'); -const selectedCommitmentFields = computed(() => { - const fields = selectedCommitment.value?.fields; - return Array.isArray(fields) ? fields : []; -}); - -const requiresPatient = computed(() => isNativeSession(selectedCommitment.value)); -const isSessionEvent = computed(() => requiresPatient.value); -// Bloqueia troca de paciente quando editando sessão que já tinha paciente vinculado -const patientLocked = computed(() => isEdit.value && isSessionEvent.value && !!props.eventRow?.paciente_id); -const hasInsurance = computed(() => !!form.value.insurance_plan_id); +// selectedCommitment, selectedCommitmentName, selectedCommitmentFields, +// requiresPatient, isSessionEvent, patientLocked, hasInsurance → composer (1B) // ── jornada ──────────────────────────────────────────────── -function _fmtH(hhmm) { - const [h, m] = String(hhmm || '00:00') - .slice(0, 5) - .split(':') - .map(Number); - return m > 0 ? `${h}h${String(m).padStart(2, '0')}` : `${h}h`; -} +// fmtJornadaHora (antes _fmtH) movido pra agendaEventHelpers.js (A66/1A) const jornadaDialog = computed(() => { const rules = props.workRules; @@ -569,7 +438,7 @@ const jornadaDialog = computed(() => { const durH = Math.floor(totalMin / 60); const durM = totalMin % 60; const durStr = durM > 0 ? `${durH}h${String(durM).padStart(2, '0')}` : `${durH}h`; - return { text: `Jornada: das ${_fmtH(inicio)} às ${_fmtH(fim)} (${durStr})`, isOff: false }; + return { text: `Jornada: das ${fmtJornadaHora(inicio)} às ${fmtJornadaHora(fim)} (${durStr})`, isOff: false }; }); // ── bloqueio de dia ──────────────────────────────────────── @@ -590,7 +459,9 @@ const isDiaFolga = computed(() => { // ── time picker ──────────────────────────────────────────── const timePickerOpen = ref(false); -const samePatientConflict = ref(null); +// samePatientConflict → useAgendaEventActions (1C-i) — watcher +// [paciente_id, dia] já está dentro do composable, samePatientConflict +// vem do destructuring acima. const modalidadeOptions = [ { label: 'Presencial', value: 'presencial' }, @@ -598,15 +469,9 @@ const modalidadeOptions = [ ]; // ── novo paciente ────────────────────────────────────────── -const cadRapidoOpen = ref(false); - -function abrirCadastroCompleto() { - const route = props.newPatientRoute || '/therapist/patients/cadastro'; - window.open(route, '_blank', 'noopener'); -} - +// cadRapidoOpen, abrirCadastroCompleto → useAgendaEventPickerBilling (1C-ii-a) +// onPatientCreatedRapido fica aqui (autocontido, usa form + cadRapidoOpen) function onPatientCreatedRapido(p) { - // auto-seleciona o paciente recém-criado if (!p) return; const nome = p.nome_completo || p.nome || p.name || ''; form.value.paciente_id = p.id; @@ -616,12 +481,10 @@ function onPatientCreatedRapido(p) { } // ── paciente picker ──────────────────────────────────────── -const pacientePickerOpen = ref(false); -const pacienteSearch = ref(''); -const pacientesLoading = ref(false); -const pacientesError = ref(''); -const patients = ref([]); - +// pacientePickerOpen, pacienteSearch, pacientesLoading, pacientesError, patients, +// selectCommitment, goBack, openPacientePicker, clearPatientsCache, loadPatients, +// selectPaciente, clearPaciente → useAgendaEventPickerBilling (1C-ii-a) +// filteredPatients depende de patients/pacienteSearch (do composable) — fica no .vue: const filteredPatients = computed(() => { const q = String(pacienteSearch.value || '') .trim() @@ -637,247 +500,11 @@ const filteredPatients = computed(() => { }); }); -function selectCommitment(c) { - if (!c?.id) return; - form.value.commitment_id = c.id; - form.value.extra_fields = {}; - if (Array.isArray(c.fields)) { - for (const f of c.fields) form.value.extra_fields[f.key] = ''; - } - step.value = 2; - if (requiresPatient.value) loadPatients(true); -} - -function goBack() { - if (isEdit.value || !allowBack.value) return; - step.value = 1; - form.value.commitment_id = null; - form.value.paciente_id = null; - form.value.paciente_nome = ''; -} - -function openPacientePicker() { - if (!requiresPatient.value) return; - pacientePickerOpen.value = true; - loadPatients(false); -} - -function clearPatientsCache() { - patients.value = []; - pacientesError.value = ''; - pacienteSearch.value = ''; -} - -async function loadPatients(force = false) { - try { - if (pacientesLoading.value) return; - if (!force && patients.value?.length) return; - pacientesError.value = ''; - pacientesLoading.value = true; - - let q = supabase.from('patients').select('id,nome_completo,email_principal,telefone,status,avatar_url,tenant_id,responsible_member_id,created_at').order('created_at', { ascending: false }).limit(500); - - if (props.tenantId) q = q.eq('tenant_id', props.tenantId); - if (props.restrictPatientsToOwner && props.patientScopeOwnerId) { - q = q.eq('responsible_member_id', props.patientScopeOwnerId); - } - - const { data, error } = await q; - if (error) throw error; - - patients.value = (data || []).map((r) => ({ - id: r.id, - nome: r.nome_completo ?? '', - email: r.email_principal ?? '', - telefone: r.telefone ?? '', - status: r.status ?? '', - avatar_url: r.avatar_url ?? '' - })); - } catch (e) { - pacientesError.value = e?.message || 'Falha ao carregar pacientes.'; - patients.value = []; - } finally { - pacientesLoading.value = false; - } -} - -function selectPaciente(p) { - if (!p?.id) return; - form.value.paciente_id = p.id; - form.value.paciente_nome = p.nome || ''; - form.value.paciente_avatar = p.avatar_url || ''; - pacientePickerOpen.value = false; -} -function clearPaciente() { - form.value.paciente_id = null; - form.value.paciente_nome = ''; - form.value.paciente_avatar = ''; - samePatientConflict.value = null; -} - -// ── quando abre ──────────────────────────────────────────── -watch( - () => props.modelValue, - async (open) => { - if (!open) return; - await nextTick(); - console.log('[AgendaEventDialog] abriu — eventRow:', JSON.parse(JSON.stringify(props.eventRow || {}))); - console.log('[AgendaEventDialog] isEdit:', isEdit.value, 'hasSerie:', hasSerie.value); - - _skipStatusWatch.value = true; - form.value = resetForm(); - await nextTick(); - _skipStatusWatch.value = false; - samePatientConflict.value = null; - recorrenciaType.value = 'avulsa'; - diasSelecionados.value = []; - dataLimiteManual.value = null; - qtdSessoesMode.value = '4'; - qtdSessoesCustom.value = 12; - editScope.value = 'somente_este'; - serieValorMode.value = 'multiplicar'; - - if (isEdit.value && form.value.paciente_id && !form.value.paciente_nome) { - supabase - .from('patients') - .select('id, nome_completo') - .eq('id', form.value.paciente_id) - .maybeSingle() - .then(({ data }) => { - if (data?.nome_completo) form.value.paciente_nome = data.nome_completo; - }); - } - - if (hasSerie.value) loadSerieEvents(); - else serieEvents.value = []; - - if (isEdit.value) { - step.value = 2; - } else { - const preset = props.presetCommitmentId; - if (preset) { - form.value.commitment_id = preset; - step.value = 2; - } else step.value = 1; - } - - clearPatientsCache(); - if (requiresPatient.value) loadPatients(true); - - // Pré-carrega serviços para auto-fill de preço - ensureServicesLoaded(); - const insuranceOwner = props.planOwnerId || props.ownerId; - if (insuranceOwner) { - await loadInsurancePlans(insuranceOwner); - // planServices é computed — atualiza automaticamente após insurancePlans carregar - } - - // Reset convênio (será restaurado por _loadCommitmentItemsForEvent se necessário) - selectedPlanService.value = null; - _restoringConvenio.value = false; - - // Reset e carrega itens de serviço do evento (commitment_services) - commitmentItems.value = []; - servicePickerSel.value = null; - // Carrega serviços para eventos reais (form.value.id) ou template para ocorrências virtuais (só recurrence_id) - // _loadCommitmentItemsForEvent determina o billingType correto (incluindo 'convenio') - if (isEdit.value && (form.value.id || props.eventRow?.recurrence_id)) { - _loadCommitmentItemsForEvent(form.value.id); - } else { - billingType.value = 'particular'; - } - } -); - -watch( - () => [props.tenantId, props.restrictPatientsToOwner, props.patientScopeOwnerId], - () => { - if (!visible.value) return; - clearPatientsCache(); - if (requiresPatient.value) loadPatients(true); - } -); - -// Auto-fill price when commitment changes (novo evento apenas) -watch( - () => form.value.commitment_id, - async (newId) => { - if (!newId || isEdit.value || !visible.value) return; - await ensureServicesLoaded(); - applyDefaultPrice(); - } -); - -watch( - () => form.value.insurance_plan_id, - (planId) => { - if (_restoringConvenio.value) return; - selectedPlanService.value = null; - form.value.insurance_plan_service_id = null; - if (!planId) { - // Limpa campos de convênio ao desmarcar - form.value.insurance_value = null; - form.value.insurance_guide_number = null; - return; - } - // Ao selecionar convênio: exclusividade — remove serviços do caminho A - commitmentItems.value = []; - servicePickerSel.value = null; - } -); - -function onProcedureSelect(psId) { - form.value.insurance_plan_service_id = psId ?? null; - if (!psId) { - form.value.insurance_value = null; - return; - } - const ps = planServices.value.find((s) => s.id === psId); - form.value.insurance_value = ps?.value != null ? Number(ps.value) : null; -} - -watch( - () => [form.value.paciente_id, form.value.dia?.toString()], - async () => { - const pid = form.value.paciente_id; - samePatientConflict.value = null; - if (!pid || !isSessionEvent.value || !visible.value) return; - - const d = form.value.dia ? new Date(form.value.dia) : new Date(); - const dayStart = new Date(d.getFullYear(), d.getMonth(), d.getDate()).toISOString(); - const dayEnd = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).toISOString(); - - let q = supabase.from('agenda_eventos').select('id, inicio_em, fim_em, titulo').eq('patient_id', pid).gte('inicio_em', dayStart).lt('inicio_em', dayEnd).limit(1); - - if (form.value.id) q = q.neq('id', form.value.id); - const { data } = await q.maybeSingle(); - samePatientConflict.value = data || null; - } -); - -// ── Check solicitações pendentes no horário ─────────────────── -const solicitacaoPendente = ref(null); - -watch( - () => [form.value.dia?.toString(), form.value.startTime], - async ([dia, startTime]) => { - solicitacaoPendente.value = null; - if (!isSessionEvent.value || !visible.value || isEdit.value) return; - if (!props.ownerId || !dia || !startTime) return; - - const d = new Date(form.value.dia); - const isoDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; - const { data } = await supabase - .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; - } -); +// 4 watchers (modelValue init, tenant/scope, solicitação pendente, online +// slots) + solicitacaoPendente ref → useAgendaEventLifecycle (1C-ii-b) +// watcher form.commitment_id, form.insurance_plan_id, onProcedureSelect +// → useAgendaEventPickerBilling (1C-ii-a) +// watcher [paciente_id, dia] → useAgendaEventActions (1C-i) function goToAgendamentosRecebidos() { visible.value = false; @@ -922,19 +549,7 @@ const duracaoOptions = computed(() => { return groups; }); -// ── time slot helpers ────────────────────────────────────────── -function hhmmToMin(hhmm) { - const [h, m] = String(hhmm || '00:00') - .slice(0, 5) - .split(':') - .map(Number); - return (h || 0) * 60 + (m || 0); -} -function minToHHMM(min) { - const h = Math.floor(min / 60) % 24; - const m = min % 60; - return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`; -} +// hhmmToMin / minToHHMM movidos pra agendaEventHelpers.js (A66/1A) // Slots disponíveis no dia selecionado (respeitando jornada + pausas + eventos existentes) const availableSlots = computed(() => { @@ -1008,38 +623,8 @@ const filteredSlots = computed(() => { return base.map((s) => ({ ...s, isOnlineConfigured: onlineConfigSet.value.has(s.hhmm) })); }); -function selectSlot(hhmm) { - // monta um Date com a hora selecionada e atualiza startTimeDate - const [h, m] = hhmm.split(':').map(Number); - const d = new Date(); - d.setHours(h, m, 0, 0); - startTimeDate.value = d; -} - -// ── online slots ────────────────────────────────────────────── -const onlineSlots = ref([]); -const loadingOnlineSlots = ref(false); - -watch( - [() => form.value.dia, () => 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 supabase.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 } -); +// selectSlot, onlineSlots/loadingOnlineSlots refs + watcher +// → useAgendaEventLifecycle (1C-ii-b) const onlineAtivo = computed(() => !!props.agendaSettings?.online_ativo); const onlineDisponivel = computed(() => (form.value.modalidade === 'online' ? onlineAtivo.value && onlineSlots.value.length > 0 : true)); @@ -1063,10 +648,11 @@ const serieHoraDisplay = computed(() => { }); // ── série: lista de sessões ──────────────────────────────── -const serieEvents = ref([]); -const serieLoading = ref(false); -const pillDeleteMenuRef = ref(null); -const pillDeleteTarget = ref(null); +// serieEvents já declarado no topo (passado ao composer via extras). +// serieLoading, pillDeleteMenuRef, pillDeleteTarget, serieCountByStatus, +// pillDeleteMenuItems, generateRuleDates, loadSerieEvents, +// onPillEditClick, onPillStatusChange, onPillDeleteClick, onPillDelete +// → useAgendaEventLifecycle (1C-ii-b) const statusOptions = [ { label: 'Agendado', value: 'agendado' }, @@ -1076,603 +662,30 @@ const statusOptions = [ { label: 'Remarcar', value: 'remarcado' } ]; -const serieCountByStatus = computed(() => { - const counts = {}; - for (const ev of serieEvents.value) { - const s = ev._status || 'agendado'; - counts[s] = (counts[s] || 0) + 1; - } - return counts; -}); +// isPast movido pra agendaEventHelpers.js (A66/1A) -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') } - ]; -}); +// agendaPerms, isSessionFuture, isArchivedPastEdit, isInativoFutureEdit, +// statusOptionsFiltered → composer (1B) +// fmtWeekdayShort / fmtDayNum / fmtMonthShort → agendaEventHelpers (1A) -function isPast(iso) { - return iso ? new Date(iso) < new Date() : false; -} - -// ── Permissões de agenda por status do paciente ─────────────────────────── -const agendaPerms = computed(() => getPatientAgendaPermissions(form.value.paciente_status || '')); - -// Sessão atual é futura? (para edição: usa inicio_em do evento original) -const isSessionFuture = computed(() => { - if (!isEdit.value) return true; - const iso = props.eventRow?.inicio_em; - return iso ? new Date(iso) > new Date() : true; -}); - -// Arquivado editando sessão passada → somente leitura -const isArchivedPastEdit = computed(() => isEdit.value && form.value.paciente_status === 'Arquivado' && !isSessionFuture.value); - -// Inativo editando sessão futura → remarcar bloqueado -const isInativoFutureEdit = computed(() => isEdit.value && form.value.paciente_status === 'Inativo' && isSessionFuture.value); - -// StatusOptions com remarcar desabilitado para Inativo -const statusOptionsFiltered = computed(() => [ - { label: 'Agendado', value: 'agendado' }, - { label: 'Realizado', value: 'realizado' }, - { label: 'Faltou', value: 'faltou' }, - { label: 'Cancelado', value: 'cancelado' }, - { label: 'Remarcar', value: 'remarcado', disabled: isInativoFutureEdit.value } -]); -function fmtWeekdayShort(iso) { - return new Date(iso).toLocaleDateString('pt-BR', { weekday: 'short' }).replace('.', '').slice(0, 3); -} -function fmtDayNum(iso) { - return new Date(iso).getDate(); -} -function fmtMonthShort(iso) { - return new Date(iso).toLocaleDateString('pt-BR', { month: 'short' }).replace('.', ''); -} - -// Gera datas de ocorrência a partir de uma regra de recorrência -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; -} - -async function loadSerieEvents() { - const rid = props.eventRow?.recurrence_id ?? props.eventRow?.serie_id ?? null; - if (!rid) { - serieEvents.value = []; - return; - } - serieLoading.value = true; - try { - // 1. Regra de recorrência - const { data: rule, error: ruleErr } = await supabase.from('recurrence_rules').select('*').eq('id', rid).maybeSingle(); - if (ruleErr) throw ruleErr; - - // 2. Exceções (canceladas, feriados, etc.) - const { data: excData } = await supabase.from('recurrence_exceptions').select('original_date, type, reason').eq('recurrence_id', rid); - const exMap = new Map((excData || []).map((e) => [e.original_date, e])); - - // 3. Eventos reais já materializados em agenda_eventos - const { data: realData } = await supabase.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])); - - // 4. Gera as datas a partir da regra - const dates = rule ? generateRuleDates(rule) : []; - const startTime = rule?.start_time || '00:00:00'; - const durMin = rule?.duration_min || 50; - - // 5. Monta a lista - 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 [sh, sm] = String(startTime).slice(0, 5).split(':').map(Number); - 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 - }; - }); - - // Adiciona reais que não estão na lista gerada (edge cases) - 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; - } -} - -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 - }); - // Se era virtual, recarrega a lista após a página materializar o evento - // (necessário para obter o id real e evitar duplo INSERT) - 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 - }) - }); -} - -// Aviso de conflito de horário (não bloqueia, só informa) -const timeConflict = computed(() => { - if (!form.value.dia || !form.value.startTime || !inicioDateTime.value) return null; - const dur = form.value.duracaoMin || 50; - const breakMin = props.agendaSettings?.session_break_min || 0; - const slotS = inicioDateTime.value.getTime(); - const slotE = slotS + dur * 60000; - - const d = new Date(form.value.dia); - const dayISO = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; - - const currentRow = props.eventRow; - const dayEvts = (props.allEvents || []).filter((e) => { - if (!e.inicio_em) return false; - // Exclui evento real pelo id - if (form.value.id && e.id === form.value.id) return false; - // Exclui ocorrência virtual pelo recurrence_id + original_date - if (currentRow?.is_occurrence && e.is_occurrence && e.recurrence_id && e.recurrence_id === currentRow.recurrence_id && String(e.original_date || '').slice(0, 10) === String(currentRow.original_date || '').slice(0, 10)) return false; - const es = new Date(e.inicio_em); - return `${es.getFullYear()}-${String(es.getMonth() + 1).padStart(2, '0')}-${String(es.getDate()).padStart(2, '0')}` === dayISO; - }); - - for (const evt of dayEvts) { - const evtS = new Date(evt.inicio_em).getTime(); - const evtE = new Date(evt.fim_em || evt.inicio_em).getTime() + breakMin * 60000; - if (slotS < evtE && slotE > evtS) { - const nome = evt.paciente_nome || 'outro compromisso'; - return `Conflito com "${nome}" às ${fmtTime(new Date(evt.inicio_em))}`; - } - } - - // Pausas do dia - const dow = d.getDay(); - const pausas = (props.pausasSemanais || []).filter((p) => p.hora_inicio && p.hora_fim && (p.dia_semana == null || Number(p.dia_semana) === dow)); - if (pausas.length && form.value.startTime) { - const [sh, sm] = form.value.startTime.split(':').map(Number); - const sS = sh * 60 + sm; - const sE = sS + dur; - for (const p of pausas) { - const [ph, pm] = String(p.hora_inicio).split(':').map(Number); - const [eh, em] = String(p.hora_fim).split(':').map(Number); - const pS = ph * 60 + pm; - const pE = eh * 60 + em; - if (sS < pE && sE > pS) return `Horário coincide com uma pausa (${p.hora_inicio}–${p.hora_fim})`; - } - } - - return null; -}); - -const startTimeDate = computed({ - get() { - const t = form.value.startTime; - if (!t) return null; - const [h, m] = String(t).split(':').map(Number); - const d = new Date(); - d.setHours(h, m, 0, 0); - return d; - }, - set(v) { - if (!v) { - form.value.startTime = null; - return; - } - form.value.startTime = `${String(v.getHours()).padStart(2, '0')}:${String(v.getMinutes()).padStart(2, '0')}`; - } -}); - -const inicioDateTime = computed(() => { - if (!form.value.dia || !form.value.startTime) return null; - const d = new Date(form.value.dia); - const [hh, mm] = String(form.value.startTime).split(':').map(Number); - d.setHours(hh, mm, 0, 0); - return d; -}); -const fimDateTime = computed(() => { - if (!inicioDateTime.value) return null; - return addMinutesDate(inicioDateTime.value, Number(form.value.duracaoMin || 50)); -}); -const dataHoraDisplay = computed(() => { - const parts = []; - if (form.value.dia) parts.push(fmtDateBR(form.value.dia)); - if (form.value.startTime) parts.push(form.value.startTime); - return parts.join(' • '); -}); -const previewRange = computed(() => { - if (!inicioDateTime.value || !fimDateTime.value) return '—'; - return `${fmtDateBR(inicioDateTime.value)} • ${fmtTime(inicioDateTime.value)} → ${fmtTime(fimDateTime.value)}`; -}); - -const computedTitulo = computed(() => { - const forced = String(form.value.titulo_custom || '').trim(); - if (forced) return forced; - const comp = selectedCommitmentName.value || 'Compromisso'; - if (requiresPatient.value) { - const nome = String(form.value.paciente_nome || '').trim(); - return nome ? `${nome} [${comp}]` : comp; - } - return comp; -}); - -const canSave = computed(() => { - if (!form.value.owner_id) return false; - if (!form.value.dia) return false; - if (!form.value.startTime) return false; - if (!form.value.commitment_id) return false; - if (requiresPatient.value && !form.value.paciente_id) return false; - if (isSessionEvent.value && billingType.value === 'particular' && commitmentItems.value.length === 0) return false; - - // ── Restrições por status do paciente ──────────────────── - if (isSessionEvent.value && form.value.paciente_status) { - const perms = agendaPerms.value; - // Criar sessão avulsa ou com recorrência: bloqueado para Inativo/Arquivado - if (!isEdit.value && !perms.canCreateSession) return false; - // Criar recorrência: bloqueado para Inativo/Arquivado - if (!isEdit.value && recorrenciaType.value !== 'avulsa' && !perms.canCreateRecurrence) return false; - // Arquivado tentando salvar sessão passada: bloqueado - if (isArchivedPastEdit.value) return false; - } - - return true; -}); - -const headerTitle = computed(() => { - if (isEdit.value) return 'Editar compromisso'; - return step.value === 1 ? 'Novo compromisso — escolha o tipo' : 'Novo compromisso'; -}); +// timeConflict, startTimeDate, inicioDateTime, fimDateTime, dataHoraDisplay, +// previewRange, computedTitulo, canSave, headerTitle → composer (1B) // ── save / delete ────────────────────────────────────────── -const EVENTO_TIPO = Object.freeze({ SESSAO: 'sessao' }); +// onSave / onDelete / onEncerrarSerie → useAgendaEventActions (1C-i) -function onSave() { - if (!canSave.value) return; +// onSendManualReminder + sendingReminder → useAgendaEventLifecycle (1C-ii-b) - const inicioISO = inicioDateTime.value?.toISOString() || null; - const fimISO = fimDateTime.value?.toISOString() || null; - - const payload = { - owner_id: form.value.owner_id, - terapeuta_id: form.value.terapeuta_id, - paciente_id: requiresPatient.value ? form.value.paciente_id : null, - patient_id: requiresPatient.value ? form.value.paciente_id : null, - tipo: EVENTO_TIPO.SESSAO, - status: form.value.status || 'agendado', - titulo: computedTitulo.value || null, - modalidade: form.value.modalidade || null, - observacoes: form.value.observacoes || null, - inicio_em: inicioISO, - fim_em: fimISO, - determined_commitment_id: form.value.commitment_id || null, - titulo_custom: form.value.titulo_custom || null, - extra_fields: Object.keys(form.value.extra_fields || {}).length ? form.value.extra_fields : null, - price: isSessionEvent.value ? (form.value.price ?? null) : null, - insurance_plan_id: isSessionEvent.value ? (form.value.insurance_plan_id ?? null) : null, - insurance_guide_number: isSessionEvent.value ? (form.value.insurance_guide_number ?? null) : null, - insurance_value: isSessionEvent.value ? (form.value.insurance_value ?? null) : null, - insurance_plan_service_id: isSessionEvent.value ? (form.value.insurance_plan_service_id ?? null) : null - }; - - // recorrência — só quando é sessão e não avulsa - let recorrencia = null; - if (isSessionEvent.value && recorrenciaType.value !== 'avulsa') { - recorrencia = { - tipo: 'recorrente', - tipoFreq: recorrenciaType.value, // 'semanal' | 'quinzenal' | 'diasEspecificos' - diaSemana: diaSemanaRecorrencia.value, - diasSemana: diasSelecionados.value, // usado em diasEspecificos - horaInicio: form.value.startTime ? `${form.value.startTime}:00` : null, - duracaoMin: form.value.duracaoMin, - dataFim: dataFimCalculada.value ? dataFimCalculada.value.toISOString() : null, - qtdSessoes: qtdSessoesEfetiva.value, - serieValorMode: serieValorMode.value, - commitmentItems: commitmentItems.value.slice() - }; - recorrencia.conflitos = ocorrenciasComConflito.value.filter((o) => o.conflict).map((o) => ({ date: o.date.toISOString().slice(0, 10), conflict: o.conflict })); - } - - // escopo de edição — só quando edita série existente - const emitEditMode = hasSerie.value ? editScope.value : null; - const emitRecurrenceId = hasSerie.value ? (props.eventRow?.recurrence_id ?? props.eventRow?.serie_id ?? null) : null; - const emitOriginalDate = hasSerie.value ? (props.eventRow?.original_date ?? null) : null; - - emit('save', { - id: form.value.id, - payload, - recorrencia, - editMode: emitEditMode, - recurrence_id: emitRecurrenceId, - original_date: emitOriginalDate, - // legado — mantido para compatibilidade - serie_id: props.eventRow?.serie_id ?? null, - serviceItems: isSessionEvent.value ? commitmentItems.value.slice() : null, - onSaved: isSessionEvent.value - ? async (eventId, { markCustomized = false } = {}) => { - await saveCommitmentItems(eventId, commitmentItems.value, { markCustomized }); - } - : null - }); -} - -function onEncerrarSerie() { - confirm.require({ - header: 'Encerrar toda a série', - message: 'Todos os agendamentos da série serão removidos permanentemente, incluindo exceções e recorrências. Esta sessão será mantida como avulsa. Esta ação é irreversível.', - icon: 'pi pi-trash', - acceptClass: 'p-button-danger', - acceptLabel: 'Sim, encerrar série', - rejectLabel: 'Cancelar', - accept: () => - emit('delete', { - id: form.value.id, - editMode: 'todos', - recurrence_id: props.eventRow?.recurrence_id ?? props.eventRow?.serie_id ?? null, - original_date: props.eventRow?.original_date ?? null, - serie_id: props.eventRow?.serie_id ?? null - }) - }); -} - -// ───── Lembrete manual WhatsApp (8.2) ───── -const sendingReminder = ref(false); -async function onSendManualReminder() { - if (!form.value?.id) return; - confirm.require({ - header: 'Enviar lembrete WhatsApp?', - message: `Vou mandar o template "lembrete_sessao" pra ${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: 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; - } - } - }); -} - -function onDelete() { - if (!form.value.id) return; - - // se for evento de série, pede escopo - if (hasSerie.value) { - const isTodos = editScope.value === '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.' : 'Esta sessão faz parte de uma série. O que deseja remover?', - icon: isTodos ? 'pi pi-trash' : 'pi pi-exclamation-triangle', - acceptClass: 'p-button-danger', - acceptLabel: isTodos ? 'Sim, encerrar série' : editScopeOptions.value.find((o) => o.value === editScope.value)?.label || 'Excluir', - rejectLabel: 'Cancelar', - accept: () => - emit('delete', { - id: form.value.id, - editMode: editScope.value, - recurrence_id: props.eventRow?.recurrence_id ?? props.eventRow?.serie_id ?? null, - original_date: props.eventRow?.original_date ?? null, - serie_id: props.eventRow?.serie_id ?? null - }) - }); - return; - } - - confirm.require({ - header: 'Excluir compromisso', - message: 'Tem certeza? Essa ação não pode ser desfeita.', - icon: 'pi pi-exclamation-triangle', - acceptClass: 'p-button-danger', - acceptLabel: 'Excluir', - rejectLabel: 'Cancelar', - accept: () => emit('delete', form.value.id) - }); -} +// onDelete → useAgendaEventActions (1C-i) // ── helpers ──────────────────────────────────────────────── -function addMinutesDate(date, min) { - const d = new Date(date); - d.setMinutes(d.getMinutes() + Number(min || 0)); - return d; -} -function fmtDateBR(d) { - const dt = d instanceof Date ? d : new Date(d); - return dt.toLocaleDateString('pt-BR', { day: '2-digit', month: 'short', year: 'numeric' }); -} -function fmtDateBRLong(d) { - const dt = d instanceof Date ? d : new Date(d); - return dt.toLocaleDateString('pt-BR', { weekday: 'short', day: '2-digit', month: 'short' }); -} -function fmtTime(d) { - if (!d) return '—'; - return new Date(d).toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' }); -} -function fmtDuracao(min) { - const m = Number(min || 0); - if (!m) return '—'; - const h = Math.floor(m / 60); - const r = m % 60; - if (h && r) return `${h}h ${r}min`; - if (h) return `${h}h`; - return `${r}min`; -} -function fmtSerieHora(hora) { - if (!hora) return '—'; - return String(hora).slice(0, 5); -} -function nomeDiaSemana(dow) { - const nomes = ['domingo', 'segunda', 'terça', 'quarta', 'quinta', 'sexta', 'sábado']; - return nomes[Number(dow ?? 0)] ?? '—'; -} -function isoToHHMM(iso) { - if (!iso) return null; - const s = String(iso); - if (s.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(s)) { - const d = new Date(s); - return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; - } - const match = s.match(/T(\d{2}):(\d{2})/); - if (match) return `${match[1]}:${match[2]}`; - const d = new Date(s); - return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; -} -function calcMinutes(a, b) { - try { - if (!a || !b) return null; - const ms = new Date(b).getTime() - new Date(a).getTime(); - return Math.max(0, Math.round(ms / 60000)); - } catch { - return null; - } -} +// addMinutesDate / fmtDateBR / fmtDateBRLong / fmtTime / fmtDuracao / +// fmtSerieHora / nomeDiaSemana / isoToHHMM / calcMinutes movidos pra +// agendaEventHelpers.js (A66/1A) -function resetForm() { - const r = props.eventRow; - const startISO = r?.inicio_em || props.initialStartISO || ''; - const endISO = r?.fim_em || props.initialEndISO || ''; - const duracaoMin = calcMinutes(startISO, endISO) || props.agendaSettings?.session_duration_min || 50; - - return { - id: r?.id || null, - owner_id: r?.owner_id || props.ownerId || '', - terapeuta_id: r?.terapeuta_id ?? null, - paciente_id: r?.paciente_id ?? null, - paciente_nome: r?.paciente_nome ?? r?.patient_name ?? '', - paciente_avatar: r?.paciente_avatar ?? '', - paciente_status: r?.paciente_status ?? '', - commitment_id: r?.determined_commitment_id ?? null, - titulo_custom: r?.titulo_custom || '', - status: r?.status || 'agendado', - observacoes: r?.observacoes || '', - dia: startISO ? new Date(startISO) : new Date(), - startTime: startISO ? isoToHHMM(startISO) : null, - duracaoMin, - modalidade: r?.modalidade || 'presencial', - conflito: null, - extra_fields: r?.extra_fields && typeof r.extra_fields === 'object' ? { ...r.extra_fields } : {}, - price: r?.price != null ? Number(r.price) : null, - insurance_plan_id: r?.insurance_plan_id ?? null, - insurance_guide_number: r?.insurance_guide_number ?? null, - insurance_value: r?.insurance_value != null ? Number(r.insurance_value) : null, - insurance_plan_service_id: r?.insurance_plan_service_id ?? null - }; -} +// resetForm → composer (1B). Se algum watcher do .vue precisa resetar +// o form (ex: ao reabrir o dialog), chamar `form.value = resetForm()` +// usando a função exportada do composer. // ── Google Calendar link ──────────────────────────────────────── const googleCalendarUrl = computed(() => { @@ -1697,21 +710,8 @@ const googleCalendarUrl = computed(() => { }); }); -function labelStatusSessao(v) { - const map = { agendado: 'Agendado', realizado: 'Realizado', faltou: 'Faltou', cancelado: 'Cancelado', remarcado: 'Remarcado' }; - return map[v] || '—'; -} -function statusSeverity(v) { - if (v === 'agendado') return 'info'; - if (v === 'realizado') return 'success'; - if (v === 'faltou') return 'warn'; - if (v === 'cancelado') return 'danger'; - if (v === 'remarcado') return 'secondary'; // cor real via classe CSS - return 'secondary'; -} -function statusExtraClass(v) { - return v === 'remarcado' ? 'tag-remarcado' : ''; -} +// labelStatusSessao / statusSeverity / statusExtraClass movidos pra +// agendaEventHelpers.js (A66/1A)