Preficicação, Convenio, Ajustes Agenda, Configurações Excessões

This commit is contained in:
Leonardo
2026-03-13 16:03:08 -03:00
parent f4b185ae17
commit 06fb369beb
30 changed files with 24851 additions and 307 deletions
+217 -24
View File
@@ -524,6 +524,7 @@ import { logEvent, logError } from '@/support/supportLogger'
import { useAgendaSettings } from '@/features/agenda/composables/useAgendaSettings'
import { useAgendaEvents } from '@/features/agenda/composables/useAgendaEvents'
import { useRecurrence } from '@/features/agenda/composables/useRecurrence'
import { useCommitmentServices } from '@/features/agenda/composables/useCommitmentServices'
import { useDeterminedCommitments } from '@/features/agenda/composables/useDeterminedCommitments'
import { useFeriados } from '@/composables/useFeriados'
@@ -538,7 +539,8 @@ const route = useRoute()
// Data inicial via query param ?date=YYYY-MM-DD (vindo de "Ver na agenda")
const _queryDate = route.query.date ? new Date(route.query.date + 'T12:00:00') : null
const toast = useToast()
const toast = useToast()
const confirm = useConfirm()
// ── Suporte técnico SaaS ────────────────────────────────────────────────────
const supportStore = useSupportDebugStore()
@@ -636,6 +638,8 @@ const {
upsertException,
} = useRecurrence()
const { saveRuleItems, propagateToSerie } = useCommitmentServices()
const ownerId = computed(() => settings.value?.owner_id || '')
// -----------------------------
@@ -946,7 +950,7 @@ async function loadMonthSearchRows () {
try {
const { data, error } = await supabase
.from('agenda_eventos')
.select('id, owner_id, tipo, status, titulo, inicio_em, fim_em, observacoes, modalidade, determined_commitment_id, patients!agenda_eventos_patient_id_fkey(nome_completo)')
.select('id, owner_id, tipo, status, titulo, inicio_em, fim_em, observacoes, modalidade, determined_commitment_id, insurance_plan_id, insurance_guide_number, insurance_value, patients!agenda_eventos_patient_id_fkey(nome_completo)')
.eq('owner_id', uid)
.is('mirror_of_event_id', null)
.gte('inicio_em', start)
@@ -975,8 +979,13 @@ watch(currentDate, (newD, oldD) => {
})
const calendarEvents = computed(() => {
// calendarRows já filtra onlySessions e une reais + virtuais
const base = mapAgendaEventosToCalendarEvents(calendarRows.value)
// separa reais e virtuais para aplicar mapAgendaEventosToCalendarEvents
// em cada grupo — as virtuais precisam do mesmo tratamento de cores
const realRows = calendarRows.value.filter(r => !r.is_occurrence)
const occRows = calendarRows.value.filter(r => r.is_occurrence)
const base = mapAgendaEventosToCalendarEvents(realRows)
const occEvents = mapAgendaEventosToCalendarEvents(occRows)
const breaks =
settings.value && currentRange.value.start && currentRange.value.end
@@ -987,7 +996,7 @@ const calendarEvents = computed(() => {
)
: []
return [...base, ...breaks, ...feriadoFcEvents.value]
return [...base, ...occEvents, ...breaks, ...feriadoFcEvents.value]
})
const visibleTitle = computed(() => {
@@ -1069,12 +1078,13 @@ const fcOptions = computed(() => ({
snapDuration,
slotLabelInterval,
slotLabelContent,
expandRows: true,
expandRows: false,
height: 'auto',
slotMinHeight: 14,
dayMaxEvents: true,
weekends: true,
eventMinHeight: 28,
eventMinHeight: 14,
businessHours: businessHours.value,
events: calendarEvents.value,
@@ -1095,8 +1105,17 @@ const fcOptions = computed(() => ({
const start = arg?.start
const end = arg?.end
if (start && end) {
const prevStart = currentRange.value.start?.toString()
const prevEnd = currentRange.value.end?.toString()
currentRange.value = { start, end }
await _reloadRange()
// Recarrega sempre que o range mudar OU quando _occurrenceRows estiver vazio
if (
start.toString() !== prevStart ||
end.toString() !== prevEnd ||
_occurrenceRows.value.length === 0
) {
await _reloadRange()
}
if (eventsError.value) toast.add({ severity: 'warn', summary: 'Compromissos', detail: eventsError.value, life: 4500 })
}
},
@@ -1273,7 +1292,7 @@ async function loadMiniMonthEvents (refDate) {
}
// 2. Ocorrências virtuais de recorrência (não existem no banco)
const occRows = await loadAndExpand(ownerId.value, start, end, [], clinicTenantId.value)
const occRows = await loadAndExpand(ownerId.value, start, end, rows.value, clinicTenantId.value)
for (const r of occRows || []) {
if (!r.inicio_em || !r.is_occurrence) continue
const ev = new Date(r.inicio_em)
@@ -1309,7 +1328,7 @@ watch(
() => loadMiniMonthEvents(miniDate.value),
{ immediate: true }
)
watch(() => rows.value?.length, () => loadMiniMonthEvents(miniDate.value))
watch(rows, () => loadMiniMonthEvents(miniDate.value))
// Fix persistência: recarrega quando ownerId fica disponível (settings são async)
watch(ownerId, (v) => { if (v) loadMiniMonthEvents(miniDate.value) })
@@ -1555,7 +1574,10 @@ function onEventClick (info) {
determined_commitment_id: ep.determined_commitment_id ?? null,
titulo_custom: ep.titulo_custom ?? null,
extra_fields: ep.extra_fields ?? null,
price: ep.price != null ? Number(ep.price) : null,
price: ep.price != null ? Number(ep.price) : null,
insurance_plan_id: ep.insurance_plan_id ?? null,
insurance_guide_number: ep.insurance_guide_number ?? null,
insurance_value: ep.insurance_value != null ? Number(ep.insurance_value) : null,
// ── recorrência (nova arquitetura) ──────────────────────────
recurrence_id: ep.recurrence_id ?? ep.recurrenceId ?? ep.serie_id ?? null,
original_date: ep.original_date ?? ep.originalDate ?? ep.recurrence_date ?? null,
@@ -1647,6 +1669,9 @@ function pickDbFields (obj) {
'recurrence_id', 'recurrence_date',
// financeiro
'price',
'insurance_plan_id',
'insurance_guide_number',
'insurance_value',
]
const out = {}
for (const k of allowed) {
@@ -1711,6 +1736,49 @@ async function onUpdateSeriesEvent ({ id, status, recurrence_date, inicio_em, fi
}
}
// Opção C — oferece geração de billing_contract após criar série recorrente com serviços.
// Chamada APÓS fechar o dialog principal para não bloquear o fluxo principal.
async function _offerBillingContract (normalized, recorrencia, tenantId) {
const n = recorrencia.qtdSessoes
const items = recorrencia.commitmentItems || []
const totalPorSessao = items.reduce((s, i) => s + (i.final_price ?? 0), 0)
const pacoteFechado = recorrencia.serieValorMode === 'dividir'
const packagePrice = pacoteFechado ? totalPorSessao : totalPorSessao * n
const perSessao = pacoteFechado ? totalPorSessao / n : totalPorSessao
const fmtB = v => Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
return new Promise(resolve => {
confirm.require({
header: 'Gerar contrato de cobrança?',
message: `${n} sessões — ${fmtB(perSessao)} por sessão. Total da série: ${fmtB(packagePrice)}.`,
icon: 'pi pi-file',
acceptLabel: 'Sim, gerar contrato',
rejectLabel: 'Agora não',
accept: async () => {
try {
const { error } = 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',
})
if (error) throw error
toast.add({ severity: 'success', summary: 'Contrato gerado', detail: `Pacote de ${n} sessões: ${fmtB(packagePrice)}.`, life: 3000 })
} catch (e) {
toast.add({ severity: 'warn', summary: 'Erro ao gerar contrato', detail: e?.message, life: 4000 })
}
resolve()
},
reject: () => resolve(),
})
})
}
async function onDialogSave (arg) {
let normalized = null
@@ -1826,34 +1894,95 @@ async function onDialogSave (arg) {
if (exErr) logError('AgendaTerapeutaPage', 'onDialogSave: erro ao inserir exceptions', exErr)
}
// Opção C — salvar template de serviços da regra
if (createdRule?.id && recorrencia.commitmentItems?.length) {
await saveRuleItems(createdRule.id, recorrencia.commitmentItems)
}
const detail = recorrencia.qtdSessoes
? `${recorrencia.qtdSessoes} sessões criadas`
: 'Série recorrente criada'
toast.add({ severity: 'success', summary: 'Série criada', detail, life: 3000 })
dialogOpen.value = false
await _reloadRange()
// Opção C — oferecer billing_contract após fechar o dialog (só com serviços + nº definido + paciente)
if (createdRule?.id && recorrencia.commitmentItems?.length && recorrencia.qtdSessoes && normalized.paciente_id) {
await _offerBillingContract(normalized, recorrencia, clinicId)
}
return
}
// ── CASO D: edição "somente_este" de ocorrência de série ───────────────
if (recurrenceId && editMode === 'somente_este') {
if (originalDate) {
let eventId = id ?? null
if (id) {
// Evento já materializado: atualiza campos + mantém exceção sincronizada
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) {
// Ocorrência ainda virtual: cria exceção + materializa para salvar commitment_services
await upsertException({
recurrence_id: recurrenceId,
tenant_id: clinicId,
original_date: originalDate,
type: 'reschedule_session',
new_date: normalized.inicio_em?.slice(0, 10),
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,
modalidade: normalized.modalidade ?? null,
titulo_custom: normalized.titulo_custom ?? null,
observacoes: normalized.observacoes ?? null,
extra_fields: normalized.extra_fields ?? null,
})
} else if (id) {
await update(id, pickDbFields(normalized))
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,
})
eventId = mat.id
}
}
}
// Opção C — salvar serviços e marcar esta ocorrência como customizada
if (eventId) await arg.onSaved?.(eventId, { markCustomized: true })
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Sessão atualizada individualmente.', life: 2500 })
dialogOpen.value = false
await _reloadRange()
@@ -1874,6 +2003,15 @@ async function onDialogSave (arg) {
observacoes: normalized.observacoes ?? null,
extra_fields: normalized.extra_fields ?? null,
})
// Opção C — atualizar template e propagar para a nova sub-série
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 })
dialogOpen.value = false
await _reloadRange()
@@ -1893,20 +2031,66 @@ async function onDialogSave (arg) {
observacoes: normalized.observacoes ?? null,
extra_fields: normalized.extra_fields ?? null,
})
// Opção C — atualizar template e propagar para toda a série
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 })
dialogOpen.value = false
await _reloadRange()
return
}
// ── CASO G: edição "todos sem exceção" — sobrescreve TUDO incluindo customizadas ──
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,
})
// Propaga para todos — incluindo services_customized=true — e reseta o flag
const serviceItemsG = arg.serviceItems
if (recurrenceId && serviceItemsG?.length) {
await saveRuleItems(recurrenceId, serviceItemsG)
await propagateToSerie(recurrenceId, serviceItemsG, { ignoreCustomized: true })
}
// Reseta services_customized para false em todos os eventos da série
await supabase
.from('agenda_eventos')
.update({ services_customized: false })
.eq('recurrence_id', recurrenceId)
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 })
dialogOpen.value = false
await _reloadRange()
return
}
// ── CASO A/B: evento avulso ou sessão única ────────────────────────────
const dbPayload = pickDbFields(normalized)
if (id) {
await update(id, dbPayload)
await arg.onSaved?.(id)
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Compromisso atualizado.', life: 2500 })
} else {
await create(dbPayload)
const created = await create(dbPayload)
await arg.onSaved?.(created.id)
toast.add({ severity: 'success', summary: 'Criado', detail: 'Compromisso criado.', life: 2500 })
}
@@ -2219,6 +2403,15 @@ onMounted(async () => {
</style>
<style>
/* ── Altura mínima dos slots ───────────────────────────── */
.fc-timegrid-slot {
height: 14px !important;
}
.fc-timegrid-slot-label {
font-size: 10px !important;
line-height: 1 !important;
}
/* ── Slot labels customizados ──────────────────────────── */
.fc-slot-label-hour {
display: inline-block;