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
+213 -20
View File
@@ -146,6 +146,8 @@
:slotMinTime="slotMinTime"
:slotMaxTime="slotMaxTime"
:slotDuration="slotDuration"
:slotMinHeight="14"
:expandRows="false"
:businessHours="businessHours"
:staff="staffCols"
:events="allEvents"
@@ -530,6 +532,7 @@ import { computed, onMounted, ref, watch, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import { useConfirm } from 'primevue/useconfirm'
import Calendar from 'primevue/calendar'
@@ -541,6 +544,7 @@ import BloqueioDialog from '@/features/agenda/components/BloqueioDialog.vue'
import { useAgendaClinicStaff } from '@/features/agenda/composables/useAgendaClinicStaff'
import { useAgendaClinicEvents } from '@/features/agenda/composables/useAgendaClinicEvents'
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'
@@ -554,7 +558,8 @@ import { supabase } from '@/lib/supabase/client'
const router = useRouter()
const route = useRoute()
const toast = useToast()
const toast = useToast()
const confirm = useConfirm()
const tenantStore = useTenantStore()
// Data inicial via query param ?date=YYYY-MM-DD (vindo de "Ver na agenda")
@@ -931,6 +936,8 @@ const {
upsertException,
} = useRecurrence()
const { saveRuleItems, propagateToSerie } = useCommitmentServices()
const EVENTO_TIPO = Object.freeze({ SESSAO: 'sessao', BLOQUEIO: 'bloqueio' })
function normalizeEventoTipo (t, fallback = EVENTO_TIPO.SESSAO) {
const s = String(t || '').trim().toLowerCase()
@@ -1064,7 +1071,7 @@ async function loadMonthSearchRows () {
try {
const { data, error } = await supabase
.from('agenda_eventos')
.select('id, owner_id, tenant_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, tenant_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('tenant_id', tid)
.in('owner_id', ids)
.is('mirror_of_event_id', null)
@@ -1207,6 +1214,8 @@ async function maybeLoadRange () {
}
async function onRangeChange ({ start, end, currentDate: cd }) {
const prevStart = pendingRange.value.start?.toString()
const prevEnd = pendingRange.value.end?.toString()
pendingRange.value = { start, end }
currentRange.value = { start, end }
const base = cd || start || new Date()
@@ -1218,7 +1227,14 @@ async function onRangeChange ({ start, end, currentDate: cd }) {
miniDate.value = normalizeDay(newDate)
}
await maybeLoadRange()
// Recarrega sempre que o range mudar OU quando _occurrenceRows estiver vazio
if (
start?.toString() !== prevStart ||
end?.toString() !== prevEnd ||
_occurrenceRows.value.length === 0
) {
await maybeLoadRange()
}
}
watch(ownerIds, async (ids) => { if (ids && ids.length) await maybeLoadRange() })
@@ -1369,7 +1385,10 @@ async 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.recurrenceId ?? ep.recurrence_id ?? ep.serie_id ?? null,
original_date: ep.originalDate ?? ep.original_date ?? ep.recurrence_date ?? null,
@@ -1502,6 +1521,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) {
@@ -1565,6 +1587,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 (basePayload, 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: basePayload.owner_id,
tenant_id: tenantId,
patient_id: basePayload.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) {
const tid = tenantId.value
if (!tid) {
@@ -1654,32 +1719,93 @@ async function onDialogSave (arg) {
await updateClinic(id, { recurrence_id: createdRule.id, recurrence_date: firstRecISO }, { tenantId: tid })
}
// 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 && basePayload.paciente_id) {
await _offerBillingContract(basePayload, recorrencia, tid)
}
return
}
// ── CASO D: edição "somente_este" ──────────────────────────────────────
if (recurrenceId && editMode === 'somente_este') {
if (originalDate) {
let eventId = id ?? null
if (id) {
// Evento já materializado: atualiza campos + mantém exceção sincronizada
await updateClinic(id, basePayload, { tenantId: tid })
if (originalDate) {
await upsertException({
recurrence_id: recurrenceId,
tenant_id: tid,
original_date: originalDate,
type: 'reschedule_session',
new_date: basePayload.inicio_em?.slice(0, 10),
new_start_time: basePayload.inicio_em ? new Date(basePayload.inicio_em).toTimeString().slice(0, 8) : null,
new_end_time: basePayload.fim_em ? new Date(basePayload.fim_em).toTimeString().slice(0, 8) : null,
modalidade: basePayload.modalidade ?? null,
titulo_custom: basePayload.titulo_custom ?? null,
observacoes: basePayload.observacoes ?? null,
extra_fields: basePayload.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: tid,
original_date: originalDate,
type: 'reschedule_session',
new_date: basePayload.inicio_em?.slice(0, 10),
recurrence_id: recurrenceId,
tenant_id: tid,
original_date: originalDate,
type: 'reschedule_session',
new_date: basePayload.inicio_em?.slice(0, 10),
new_start_time: basePayload.inicio_em ? new Date(basePayload.inicio_em).toTimeString().slice(0, 8) : null,
new_end_time: basePayload.fim_em ? new Date(basePayload.fim_em).toTimeString().slice(0, 8) : null,
modalidade: basePayload.modalidade ?? null,
titulo_custom: basePayload.titulo_custom ?? null,
observacoes: basePayload.observacoes ?? null,
extra_fields: basePayload.extra_fields ?? null,
modalidade: basePayload.modalidade ?? null,
titulo_custom: basePayload.titulo_custom ?? null,
observacoes: basePayload.observacoes ?? null,
extra_fields: basePayload.extra_fields ?? null,
})
} else if (id) {
await updateClinic(id, basePayload, { tenantId: tid })
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 createClinic({
owner_id: basePayload.owner_id,
tenant_id: tid,
recurrence_id: recurrenceId,
recurrence_date: originalDate,
tipo: basePayload.tipo,
status: basePayload.status,
inicio_em: basePayload.inicio_em,
fim_em: basePayload.fim_em,
titulo: basePayload.titulo,
patient_id: basePayload.patient_id,
determined_commitment_id: basePayload.determined_commitment_id,
modalidade: basePayload.modalidade ?? 'presencial',
observacoes: basePayload.observacoes ?? null,
extra_fields: basePayload.extra_fields ?? null,
}, { tenantId: tid })
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()
@@ -1700,6 +1826,15 @@ async function onDialogSave (arg) {
observacoes: basePayload.observacoes ?? null,
extra_fields: basePayload.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()
@@ -1719,15 +1854,64 @@ async function onDialogSave (arg) {
observacoes: basePayload.observacoes ?? null,
extra_fields: basePayload.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(basePayload.inicio_em)
await updateRule(recurrenceId, {
weekdays: [startDate.getDay()],
start_time: startDate.toTimeString().slice(0, 8),
end_time: basePayload.fim_em ? new Date(basePayload.fim_em).toTimeString().slice(0, 8) : undefined,
duration_min: recorrencia?.duracaoMin ?? 50,
modalidade: basePayload.modalidade ?? 'presencial',
titulo_custom: basePayload.titulo_custom ?? null,
observacoes: basePayload.observacoes ?? null,
extra_fields: basePayload.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 ────────────────────────────
if (id) await updateClinic(id, basePayload, { tenantId: tid })
else await createClinic(basePayload, { tenantId: tid })
if (id) {
await updateClinic(id, basePayload, { tenantId: tid })
await arg.onSaved?.(id)
} else {
const created = await createClinic(basePayload, { tenantId: tid })
await arg.onSaved?.(created.id)
}
dialogOpen.value = false
await _reloadRange()
@@ -1977,7 +2161,7 @@ async function loadMiniMonthEvents (refDate) {
// 2. Ocorrências virtuais de recorrência (não existem no banco)
for (const oid of ownerIds.value || []) {
const occRows = await loadAndExpand(oid, start, end, [], tenantId.value)
const occRows = await loadAndExpand(oid, start, end, rows.value.filter(r => r.owner_id === oid), tenantId.value)
for (const r of occRows || []) {
if (!r.inicio_em || !r.is_occurrence) continue
const ev = new Date(r.inicio_em)
@@ -2014,7 +2198,7 @@ watch(
() => loadMiniMonthEvents(miniDate.value),
{ immediate: true }
)
watch(() => rows.value?.length, () => loadMiniMonthEvents(miniDate.value))
watch(rows, () => loadMiniMonthEvents(miniDate.value))
// Fix persistência: recarrega quando clinicOwnerId fica disponível (settings são async)
watch(clinicOwnerId, (v) => { if (v) loadMiniMonthEvents(miniDate.value) })
@@ -2276,6 +2460,15 @@ function goRecorrencias () { router.push({ name: 'admin-agenda-recorrencias' })
</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;
}
/* Mini calendário — colorir dias por expediente */
.p-datepicker-day.mini-day-work:not(.p-datepicker-day-selected) {
background: rgba(34, 197, 94, 0.25);