Ajuste Convenios e Particular

This commit is contained in:
Leonardo
2026-03-13 21:09:34 -03:00
parent 06fb369beb
commit 587079e414
13 changed files with 971 additions and 277 deletions
@@ -506,72 +506,36 @@
:options="editScopeOptions"
optionLabel="label"
optionValue="value"
:optionDisabled="o => !!o.disabled"
class="w-full"
size="small"
/>
<Message v-if="isFirstOccurrence" severity="info" :closable="false" class="mt-2">
<span class="text-sm">
Esta é a primeira sessão da série.
Para alterar todas as ocorrências, use <b>Todos</b> ou <b>Todos sem exceção</b>.
</span>
</Message>
</div>
<!-- CONVÊNIO ( sessão) -->
<!-- FINANCEIRO ( sessão) -->
<div v-if="isSessionEvent" class="side-card mb-3">
<div class="side-card__title mb-2">Convênio</div>
<div class="flex flex-col gap-2">
<Select
v-model="form.insurance_plan_id"
:options="insurancePlans"
optionLabel="name"
optionValue="id"
placeholder="Particular (sem convênio)"
showClear
class="w-full"
size="small"
/>
<template v-if="hasInsurance">
<div class="flex gap-2">
<div class="flex-1">
<label class="text-xs text-color-secondary mb-1 block"> da Guia</label>
<InputText
v-model="form.insurance_guide_number"
placeholder="Ex: 123456789"
class="w-full"
size="small"
/>
</div>
<div class="flex-1">
<label class="text-xs text-color-secondary mb-1 block">Valor do Convênio (R$)</label>
<InputNumber
v-model="form.insurance_value"
mode="currency"
currency="BRL"
locale="pt-BR"
class="w-full"
size="small"
/>
</div>
</div>
</template>
</div>
</div>
<!-- RECORRÊNCIA ( sessão) -->
<div v-if="isSessionEvent" class="side-card">
<!-- SelectButton: Gratuito / Particular / Convênio -->
<SelectButton
v-model="billingType"
:options="billingTypeOptions"
optionLabel="label"
optionValue="value"
class="w-full mb-3"
/>
<!-- Serviços / Valor da sessão -->
<div class="mb-3">
<!-- SelectButton Gratuito/Pago -->
<SelectButton
v-model="billingType"
:options="billingTypeOptions"
optionLabel="label"
optionValue="value"
class="mb-3 w-full"
/>
<!-- Seletor de serviço ( quando Pago e serviços cadastrados) -->
<div v-if="billingType === 'pago' && services.length" class="flex gap-2 mb-2">
<!-- PARTICULAR: seletor de serviço -->
<template v-if="billingType === 'particular'">
<div v-if="services.filter(s => s.active).length" class="flex gap-2 mb-2">
<Select
v-model="servicePickerSel"
:options="services"
:options="services.filter(s => s.active)"
optionLabel="name"
optionValue="id"
placeholder="Adicionar serviço..."
@@ -580,6 +544,87 @@
@update:modelValue="(id) => { addItem(services.find(s => s.id === id)); servicePickerSel = null }"
/>
</div>
</template>
<!-- CONVÊNIO: fluxo progressivo -->
<template v-if="billingType === 'convenio'">
<!-- PASSO 1 Select do plano (sempre visível) -->
<Select
v-model="form.insurance_plan_id"
:options="activePlans"
optionLabel="name"
optionValue="id"
placeholder="Selecionar convênio..."
showClear
class="w-full mb-2"
size="small"
/>
<!-- PASSO 2 Procedimento (visível quando plano selecionado) -->
<template v-if="hasInsurance">
<template v-if="planServices.length > 0">
<label class="text-xs text-color-secondary mb-1 block">Procedimento</label>
<Select
v-model="selectedPlanService"
:options="planServices"
optionLabel="name"
optionValue="id"
placeholder="Selecionar procedimento..."
showClear
class="w-full mb-2"
size="small"
@update:modelValue="onProcedureSelect"
/>
</template>
<div v-else class="flex items-center justify-between gap-2 mb-2 p-2 rounded-lg bg-surface-100">
<span class="text-xs text-color-secondary">Este convênio não tem procedimentos cadastrados.</span>
<Button
label="Cadastrar"
icon="pi pi-plus"
size="small"
severity="secondary"
outlined
class="rounded-full shrink-0 text-xs h-7"
@click="goToConveniosConfig"
/>
</div>
<!-- PASSO 3 da Guia + Valor (visível após selecionar procedimento ou sem procedimentos) -->
<template v-if="selectedPlanService != null || planServices.length === 0">
<div class="flex gap-2">
<div class="flex-1">
<label class="text-xs text-color-secondary mb-1 block"> da Guia</label>
<InputText
v-model="form.insurance_guide_number"
placeholder="Ex: 123456789"
class="w-full"
size="small"
/>
</div>
<div class="flex-1">
<label class="text-xs text-color-secondary mb-1 block">Valor (R$)</label>
<InputNumber
v-model="form.insurance_value"
mode="currency"
currency="BRL"
locale="pt-BR"
:readonly="planServices.length > 0 && selectedPlanService != null"
class="w-full"
size="small"
/>
</div>
</div>
</template>
</template>
</template>
</div>
<!-- RECORRÊNCIA ( sessão) -->
<div v-if="isSessionEvent" class="side-card">
<div class="mb-3">
<!-- Lista de itens adicionados -->
<div v-if="commitmentItems.length" class="commitment-items-list mb-2">
@@ -1063,6 +1108,7 @@ const props = defineProps({
initialEndISO: { type: String, default: '' },
ownerId: { type: String, default: '' },
planOwnerId: { type: String, default: '' }, // owner dos convênios (clínica); fallback: ownerId
allowOwnerEdit: { type: Boolean, default: false },
ownerOptions: { type: Array, default: () => [] },
@@ -1110,12 +1156,27 @@ const currentRecurrenceDate = computed(() =>
)
const editScope = ref('somente_este')
const editScopeOptions = [
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' },
{ 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'
@@ -1288,6 +1349,17 @@ const { services, getDefaultPrice, load: loadServices } = useServices()
const { loadItems: _csLoadItems, saveItems: saveCommitmentItems, loadItemsOrTemplate: _csLoadItemsOrTemplate } = useCommitmentServices()
const { loadActive: loadActiveDiscount } = usePatientDiscounts()
const { plans: insurancePlans, load: loadInsurancePlans } = useInsurancePlans()
const selectedPlanService = ref(null)
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 () {
@@ -1298,7 +1370,7 @@ async function ensureServicesLoaded () {
function applyDefaultPrice () {
// Pula quando pago: o preço vem dos commitmentItems, não de um default
if (billingType.value === 'pago') return
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()
@@ -1311,16 +1383,32 @@ const commitmentItems = ref([])
const servicePickerSel = ref(null)
const serieValorMode = ref('multiplicar') // 'multiplicar' | 'dividir'
const billingType = ref('pago') // 'gratuito' | 'pago'
const billingType = ref('particular') // 'gratuito' | 'particular' | 'convenio'
const _restoringConvenio = ref(false) // flag para ignorar watch durante restauração
const billingTypeOptions = [
{ label: 'Gratuito', value: 'gratuito' },
{ label: 'Pago', value: 'pago' },
{ 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
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
}
})
@@ -1426,16 +1514,46 @@ function onItemChange (item) {
async function _loadCommitmentItemsForEvent (eventId) {
const ruleId = props.eventRow?.recurrence_id ?? null
const isCustomized = props.eventRow?.services_customized ?? false
if (!eventId && !ruleId) { commitmentItems.value = []; billingType.value = 'gratuito'; return }
// 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)
billingType.value = commitmentItems.value.length > 0 ? 'pago' : 'gratuito'
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 = []
billingType.value = 'gratuito'
if (origPlanId) applyConvenio()
else billingType.value = 'gratuito'
}
}
@@ -1675,23 +1793,25 @@ watch(
// Pré-carrega serviços para auto-fill de preço
ensureServicesLoaded()
if (props.ownerId) {
await loadInsurancePlans(props.ownerId)
// Se já tem convênio selecionado (edição), aplica o valor padrão agora que os planos estão carregados
const planId = form.value.insurance_plan_id
if (planId && !form.value.insurance_value) {
const plan = insurancePlans.value.find(p => p.id === planId)
if (plan?.default_value) form.value.insurance_value = plan.default_value
}
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
billingType.value = 'pago'
// 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'
}
}
)
@@ -1718,12 +1838,28 @@ watch(
watch(
() => form.value.insurance_plan_id,
(planId) => {
if (!planId) { form.value.insurance_value = null; return }
const plan = insurancePlans.value.find(p => p.id === planId)
if (plan?.default_value) form.value.insurance_value = plan.default_value
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 () => {
@@ -1779,6 +1915,12 @@ function goToAgendamentosRecebidos () {
router.push(`${prefix}/agendamentos-recebidos`)
}
function goToConveniosConfig () {
visible.value = false
const prefix = route.path.startsWith('/admin') ? '/admin' : '/therapist'
router.push(`${prefix}/configuracoes/convenios`)
}
// ── duração / slots ────────────────────────────────────────
// grouped duration options: default preset first, then com pausa, then sem pausa
const duracaoOptions = computed(() => {
@@ -2260,7 +2402,7 @@ const canSave = computed(() => {
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 === 'pago' && commitmentItems.value.length === 0) return false
if (isSessionEvent.value && billingType.value === 'particular' && commitmentItems.value.length === 0) return false
return true
})
@@ -2294,9 +2436,10 @@ function onSave () {
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_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
@@ -2375,7 +2518,7 @@ function onDelete () {
: '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.find(o => o.value === editScope.value)?.label || 'Excluir'),
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,
@@ -2478,9 +2621,10 @@ function resetForm () {
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_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,
}
}