Preficicação, Convenio, Ajustes Agenda, Configurações Excessões
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user