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
@@ -494,29 +494,178 @@
</div>
<div v-if="isSessionEvent" class="summary-row">
<i class="pi pi-wallet summary-icon" />
<span>{{ form.price != null ? Number(form.price).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }) : '—' }}</span>
<span>{{ displayPrice != null ? fmtBRL(displayPrice) : '—' }}</span>
</div>
</div>
<!-- ESCOPO DE EDIÇÃO em modo edição de série -->
<div v-if="isEdit && hasSerie" class="side-card mb-3">
<div class="side-card__title mb-2">Aplicar alterações em</div>
<SelectButton
v-model="editScope"
:options="editScopeOptions"
optionLabel="label"
optionValue="value"
class="w-full"
size="small"
/>
</div>
<!-- CONVÊNIO ( 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">
<!-- Valor da sessão -->
<!-- Serviços / Valor da sessão -->
<div class="mb-3">
<FloatLabel variant="on">
<InputNumber
v-model="form.price"
inputId="aed-price-side"
mode="currency"
currency="BRL"
locale="pt-BR"
:min="0"
:max="99999"
:minFractionDigits="2"
fluid
<!-- 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">
<Select
v-model="servicePickerSel"
:options="services"
optionLabel="name"
optionValue="id"
placeholder="Adicionar serviço..."
class="flex-1"
size="small"
@update:modelValue="(id) => { addItem(services.find(s => s.id === id)); servicePickerSel = null }"
/>
<label for="aed-price-side">Valor da sessão (R$)</label>
</FloatLabel>
</div>
<!-- Lista de itens adicionados -->
<div v-if="commitmentItems.length" class="commitment-items-list mb-2">
<div
v-for="(item, idx) in commitmentItems"
:key="idx"
class="commitment-item-row"
>
<!-- linha 1: nome + remover -->
<div class="commitment-item-header">
<span class="commitment-item-name">{{ item.service_name }}</span>
<Button
icon="pi pi-times"
size="small"
severity="danger"
text
@click="removeItem(idx)"
/>
</div>
<!-- linha 2: qtd | preço unit (editável) | desconto % | desconto fixo | total -->
<div class="commitment-item-controls">
<div class="commitment-item-field">
<label class="commitment-item-label">Qtd</label>
<InputNumber
v-model="item.quantity"
:min="1"
:max="99"
size="small"
inputClass="w-12 text-center"
@update:modelValue="onItemChange(item)"
/>
</div>
<div class="commitment-item-field">
<label class="commitment-item-label">Preço unit.</label>
<InputNumber
v-model="item.unit_price"
mode="currency"
currency="BRL"
locale="pt-BR"
:min="0"
:minFractionDigits="2"
size="small"
inputClass="w-24"
@update:modelValue="onItemChange(item)"
/>
</div>
<div class="commitment-item-field">
<label class="commitment-item-label">Desc %</label>
<InputNumber
v-model="item.discount_pct"
:min="0"
:max="100"
suffix="%"
size="small"
inputClass="w-16 text-center"
@update:modelValue="onItemChange(item)"
/>
</div>
<div class="commitment-item-field">
<label class="commitment-item-label">Desc R$</label>
<InputNumber
v-model="item.discount_flat"
mode="currency"
currency="BRL"
locale="pt-BR"
:min="0"
:minFractionDigits="2"
size="small"
inputClass="w-20"
@update:modelValue="onItemChange(item)"
/>
</div>
<div class="commitment-item-field commitment-item-field--final">
<label class="commitment-item-label">Total</label>
<span class="commitment-item-price">{{ fmtBRL(item.final_price) }}</span>
</div>
</div>
</div>
<div class="commitment-items-total">
<span class="text-sm text-color-secondary">Total da sessão</span>
<span class="font-semibold">{{ fmtBRL(totalFromItems) }}</span>
</div>
</div>
</div>
<!-- Observação -->
@@ -644,6 +793,22 @@
<span class="text-xs">{{ totalConflitos }} sessão(ões) com conflito serão marcadas automaticamente para ajuste.</span>
</Message>
</div>
<!-- Modo de valor da série informativo, quando serviços + recorrência -->
<div v-if="recorrenciaType !== 'avulsa' && commitmentItems.length > 0" class="mb-1 mt-3">
<label class="block text-xs font-semibold text-color-secondary mb-1.5">Como interpretar o valor</label>
<SelectButton
v-model="serieValorMode"
:options="[{ label: 'Por sessão', value: 'multiplicar' }, { label: 'Pacote fechado', value: 'dividir' }]"
optionLabel="label"
optionValue="value"
size="small"
class="w-full mb-2"
/>
<Message v-if="serieValorAviso" severity="info" :closable="false">
<span class="text-xs">{{ serieValorAviso }}</span>
</Message>
</div>
</template>
</div>
@@ -823,7 +988,11 @@
optionGroupChildren="items"
variant="filled"
class="w-full"
:disabled="isDynamic && commitmentItems.length > 0"
/>
<small v-if="isDynamic && commitmentItems.length > 0" class="text-color-secondary text-xs mt-1 block">
Calculado pelos serviços adicionados
</small>
</div>
</div>
@@ -875,7 +1044,10 @@ import Message from 'primevue/message'
import { useConfirm } from 'primevue/useconfirm'
import { supabase } from '@/lib/supabase/client'
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue'
import { useProfessionalPricing } from '@/features/agenda/composables/useProfessionalPricing'
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'
function patientInitials (nome) {
const parts = String(nome || '').trim().split(/\s+/).filter(Boolean)
@@ -942,6 +1114,7 @@ const editScopeOptions = [
{ value: 'somente_este', label: 'Somente esta sessão' },
{ value: 'este_e_seguintes', label: 'Esta e as seguintes' },
{ value: 'todos', label: 'Todas da série' },
{ value: 'todos_sem_excecao', label: 'Todas sem exceção' },
]
// ── recorrência (criação / sessão avulsa) ──────────────────
@@ -1110,24 +1283,167 @@ function isNativeSession (c) {
const form = ref(resetForm())
// ── Precificação ────────────────────────────────────────────────────
const { getPriceFor, load: loadPricing } = useProfessionalPricing()
let _pricingLoaded = false
// ── Precificação / Serviços ─────────────────────────────────────────
const { services, getDefaultPrice, load: loadServices } = useServices()
const { loadItems: _csLoadItems, saveItems: saveCommitmentItems, loadItemsOrTemplate: _csLoadItemsOrTemplate } = useCommitmentServices()
const { loadActive: loadActiveDiscount } = usePatientDiscounts()
const { plans: insurancePlans, load: loadInsurancePlans } = useInsurancePlans()
let _servicesLoaded = false
async function ensurePricingLoaded () {
if (_pricingLoaded || !props.ownerId) return
_pricingLoaded = true
await loadPricing(props.ownerId)
async function ensureServicesLoaded () {
if (_servicesLoaded || !props.ownerId) return
_servicesLoaded = true
await loadServices(props.ownerId)
}
function applyPriceForCommitment (commitmentId) {
function applyDefaultPrice () {
// Pula quando pago: o preço vem dos commitmentItems, não de um default
if (billingType.value === 'pago') return
// Só auto-preenche se price ainda não foi definido manualmente (ou é novo evento)
if (!isEdit.value) {
const suggested = getPriceFor(commitmentId)
const suggested = getDefaultPrice()
if (suggested != null) form.value.price = suggested
}
}
// ── Itens de serviço (commitment_services) ──────────────────────────
const commitmentItems = ref([])
const servicePickerSel = ref(null)
const serieValorMode = ref('multiplicar') // 'multiplicar' | 'dividir'
const billingType = ref('pago') // 'gratuito' | 'pago'
const billingTypeOptions = [
{ label: 'Gratuito', value: 'gratuito' },
{ label: 'Pago', value: 'pago' },
]
watch(billingType, (val) => {
if (val === 'gratuito') {
commitmentItems.value = []
form.value.price = 0
}
})
const isDynamic = computed(() =>
(props.agendaSettings?.slot_mode ?? 'fixed') === 'dynamic'
)
const totalFromItems = computed(() =>
commitmentItems.value.reduce((sum, item) => sum + (item.final_price ?? 0), 0)
)
// Duração calculada como soma de services.duration_min dos itens (slot_mode=dynamic)
const dynamicDuration = computed(() => {
if (!isDynamic.value) return null
return commitmentItems.value.reduce((sum, item) => {
const svc = services.value.find(s => s.id === item.service_id)
return sum + (svc?.duration_min ?? 0)
}, 0)
})
// Preço exibido no resumo: total dos itens quando há itens, form.price caso contrário
const displayPrice = computed(() =>
commitmentItems.value.length > 0 ? totalFromItems.value : form.value.price
)
// Aviso informativo de valor total da série (não altera os valores gravados)
const serieValorAviso = computed(() => {
if (recorrenciaType.value === 'avulsa' || !commitmentItems.value.length) return null
const n = qtdSessoesEfetiva.value
if (!n || !totalFromItems.value) return null
if (serieValorMode.value === 'multiplicar') {
return `Total da série: ${fmtBRL(totalFromItems.value * n)} (${fmtBRL(totalFromItems.value)} × ${n} sessões)`
}
return `Valor por sessão: ${fmtBRL(totalFromItems.value / n)} (${fmtBRL(totalFromItems.value)} ÷ ${n} sessões)`
})
// Sync: total dos itens → form.price
watch(totalFromItems, (total) => {
if (commitmentItems.value.length > 0) form.value.price = total
})
// Sync: duração dinâmica → form.duracaoMin (slot_mode=dynamic)
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)
}
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
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)
}
}
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
if (!eventId && !ruleId) { commitmentItems.value = []; billingType.value = 'gratuito'; return }
try {
commitmentItems.value = ruleId
? await _csLoadItemsOrTemplate(eventId, ruleId, { allowEmpty: isCustomized })
: await _csLoadItems(eventId)
billingType.value = commitmentItems.value.length > 0 ? 'pago' : 'gratuito'
} catch (e) {
console.warn('[AgendaEventDialog] commitment_services load error:', e?.message)
commitmentItems.value = []
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
@@ -1143,6 +1459,7 @@ 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)
// ── jornada ────────────────────────────────────────────────
function _fmtH (hhmm) {
@@ -1329,6 +1646,7 @@ watch(
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
@@ -1355,8 +1673,26 @@ watch(
clearPatientsCache()
if (requiresPatient.value) loadPatients(true)
// Pré-carrega precificação para auto-fill
ensurePricingLoaded()
// 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
}
}
// 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)
if (isEdit.value && (form.value.id || props.eventRow?.recurrence_id)) {
_loadCommitmentItemsForEvent(form.value.id)
}
}
)
@@ -1374,8 +1710,17 @@ watch(
() => form.value.commitment_id,
async (newId) => {
if (!newId || isEdit.value || !visible.value) return
await ensurePricingLoaded()
applyPriceForCommitment(newId)
await ensureServicesLoaded()
applyDefaultPrice()
}
)
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
}
)
@@ -1915,6 +2260,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
return true
})
@@ -1948,6 +2294,9 @@ 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,
}
// recorrência — só quando é sessão e não avulsa
@@ -1962,6 +2311,8 @@ function onSave () {
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)
@@ -1984,6 +2335,12 @@ function onSave () {
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,
})
}
@@ -2121,6 +2478,9 @@ 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,
}
}
@@ -2762,4 +3122,68 @@ function statusSeverity (v) {
border-radius: 999px; padding: .1rem .45rem; flex-shrink: 0; white-space: nowrap;
}
.serie-pill__del { flex-shrink: 0; width: 2rem; }
/* ── Commitment items (serviços vinculados ao evento) ── */
.commitment-items-list {
display: flex;
flex-direction: column;
gap: .35rem;
border: 1px solid var(--surface-border);
border-radius: .5rem;
padding: .5rem;
}
.commitment-item-row {
margin-bottom: 4px;
}
.commitment-item-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 4px;
}
.commitment-item-name {
flex: 1;
font-size: .85rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.commitment-item-controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: flex-end;
padding-bottom: 8px;
border-bottom: 1px solid var(--p-content-border-color);
margin-bottom: 8px;
}
.commitment-item-field {
display: flex;
flex-direction: column;
gap: 2px;
}
.commitment-item-field--final {
margin-left: auto;
}
.commitment-item-label {
font-size: 0.65rem;
color: var(--p-text-muted-color);
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.commitment-item-price {
font-size: .85rem;
font-weight: 600;
white-space: nowrap;
min-width: 5rem;
text-align: right;
}
.commitment-items-total {
display: flex;
justify-content: space-between;
padding-top: .35rem;
margin-top: .25rem;
border-top: 1px solid var(--surface-border);
}
</style>
@@ -33,6 +33,7 @@ const BASE_SELECT = `
determined_commitment_id, link_online, extra_fields, modalidade,
recurrence_id, recurrence_date,
mirror_of_event_id, price,
insurance_plan_id, insurance_guide_number, insurance_value,
patients!agenda_eventos_patient_id_fkey (
id, nome_completo, avatar_url
),
@@ -0,0 +1,225 @@
// src/features/agenda/composables/useCommitmentServices.js
//
// CRUD de commitment_services — itens de serviço vinculados a um evento.
// CRUD de recurrence_rule_services — template de serviços de uma regra de recorrência.
//
// Interface pública:
// loadItems(eventId) → Array<CommitmentItem>
// saveItems(eventId, items, opts?) → void (delete+insert; opts.markCustomized marca services_customized no evento)
// loadRuleItems(ruleId) → Array<CommitmentItem>
// saveRuleItems(ruleId, items) → void (delete+insert no template da regra)
// loadItemsOrTemplate(eventId, ruleId) → Array<CommitmentItem> (próprios ou template)
// propagateToSerie(ruleId, items, opts?) → void (ocorrências materializadas com services_customized=false)
import { supabase } from '@/lib/supabase/client'
// Shape interno de CommitmentItem:
// {
// service_id: uuid,
// service_name: string, // display only — não gravado no banco
// quantity: number,
// unit_price: number, // snapshot de services.price no momento da adição
// discount_pct: number,
// discount_flat: number,
// final_price: number,
// }
/** Mapeia uma linha do banco para CommitmentItem (compartilhado entre commitment_services e recurrence_rule_services) */
function _mapRow (r) {
return {
service_id: r.service_id,
service_name: r.services?.name ?? '',
quantity: Number(r.quantity),
unit_price: Number(r.unit_price),
discount_pct: Number(r.discount_pct ?? 0),
discount_flat: Number(r.discount_flat ?? 0),
final_price: Number(r.final_price),
}
}
export function useCommitmentServices () {
// ── Carregar itens de um evento ──────────────────────────────────────
async function loadItems (eventId) {
if (!eventId) return []
const { data, error } = await supabase
.from('commitment_services')
.select('service_id, quantity, unit_price, discount_pct, discount_flat, final_price, services(name)')
.eq('commitment_id', eventId)
.order('created_at', { ascending: true })
if (error) throw error
return (data || []).map(_mapRow)
}
// ── Salvar itens de um evento ────────────────────────────────────────
// Estratégia: DELETE dos itens existentes + INSERT dos novos.
// Garante idempotência em edições sem risco de duplicatas.
//
// opts.markCustomized = true: após salvar, marca services_customized = true
// no agenda_eventos correspondente, impedindo que edições do evento raiz
// sobrescrevam os serviços desta ocorrência individual.
async function saveItems (eventId, items, { markCustomized = false } = {}) {
if (!eventId) throw new Error('eventId é obrigatório para salvar commitment_services.')
// 1. Remove itens existentes deste evento
const { error: deleteError } = await supabase
.from('commitment_services')
.delete()
.eq('commitment_id', eventId)
if (deleteError) throw deleteError
// 2. Insere os novos itens (se houver)
if (items?.length) {
const rows = items.map(item => ({
commitment_id: eventId,
service_id: item.service_id,
quantity: item.quantity,
unit_price: item.unit_price,
discount_pct: item.discount_pct ?? 0,
discount_flat: item.discount_flat ?? 0,
final_price: item.final_price,
}))
const { error: insertError } = await supabase
.from('commitment_services')
.insert(rows)
if (insertError) throw insertError
}
// 3. Marca a ocorrência como customizada (impede sobrescrita por edições do raiz)
if (markCustomized) {
const { error: updateError } = await supabase
.from('agenda_eventos')
.update({ services_customized: true })
.eq('id', eventId)
if (updateError) throw updateError
}
}
// ── Carregar template de serviços de uma regra ───────────────────────
// Retorna os itens armazenados em recurrence_rule_services para a regra.
async function loadRuleItems (ruleId) {
if (!ruleId) return []
const { data, error } = await supabase
.from('recurrence_rule_services')
.select('service_id, quantity, unit_price, discount_pct, discount_flat, final_price, services(name)')
.eq('rule_id', ruleId)
.order('created_at', { ascending: true })
if (error) throw error
return (data || []).map(_mapRow)
}
// ── Salvar template de serviços de uma regra ─────────────────────────
// Estratégia: DELETE + INSERT — mesmo padrão de saveItems.
// Chamado ao criar uma recorrência com serviços ou ao editar o evento
// raiz com escopo 'todos' / 'este_e_seguintes'.
async function saveRuleItems (ruleId, items) {
if (!ruleId) throw new Error('ruleId é obrigatório para salvar recurrence_rule_services.')
const { error: deleteError } = await supabase
.from('recurrence_rule_services')
.delete()
.eq('rule_id', ruleId)
if (deleteError) throw deleteError
if (!items?.length) return
const rows = items.map(item => ({
rule_id: ruleId,
service_id: item.service_id,
quantity: item.quantity,
unit_price: item.unit_price,
discount_pct: item.discount_pct ?? 0,
discount_flat: item.discount_flat ?? 0,
final_price: item.final_price,
}))
const { error: insertError } = await supabase
.from('recurrence_rule_services')
.insert(rows)
if (insertError) throw insertError
}
// ── Carregar itens próprios ou herdar template da regra ──────────────
// Retorna os commitment_services do evento se existirem.
// Se o evento não tiver itens próprios e ruleId for fornecido,
// retorna o template da regra (ocorrência ainda não customizada).
async function loadItemsOrTemplate (eventId, ruleId, { allowEmpty = false } = {}) {
const own = await loadItems(eventId)
if (own.length > 0) return own
if (allowEmpty) return []
if (ruleId) return loadRuleItems(ruleId)
return []
}
// ── Propagar itens para ocorrências materializadas da série ──────────
// Atualiza commitment_services nos agenda_eventos com recurrence_id = ruleId
// onde services_customized = false (não foram editados individualmente).
//
// opts.fromDate: string ISO 'YYYY-MM-DD' — limita a ocorrências a partir
// dessa data inclusive (escopo 'este_e_seguintes'). null = todas da série.
async function propagateToSerie (ruleId, items, { fromDate = null, ignoreCustomized = false } = {}) {
if (!ruleId) return
// Busca IDs das ocorrências materializadas elegíveis
let q = supabase
.from('agenda_eventos')
.select('id')
.eq('recurrence_id', ruleId)
if (!ignoreCustomized) {
q = q.eq('services_customized', false)
}
if (fromDate) {
q = q.gte('inicio_em', fromDate)
}
const { data: events, error: queryError } = await q
if (queryError) throw queryError
if (!events?.length) return
// Para cada evento elegível: delete + insert (padrão idempotente)
for (const ev of events) {
const { error: delErr } = await supabase
.from('commitment_services')
.delete()
.eq('commitment_id', ev.id)
if (delErr) throw delErr
if (items?.length) {
const rows = items.map(item => ({
commitment_id: ev.id,
service_id: item.service_id,
quantity: item.quantity,
unit_price: item.unit_price,
discount_pct: item.discount_pct ?? 0,
discount_flat: item.discount_flat ?? 0,
final_price: item.final_price,
}))
const { error: insErr } = await supabase
.from('commitment_services')
.insert(rows)
if (insErr) throw insErr
}
}
}
return {
loadItems,
saveItems,
loadRuleItems,
saveRuleItems,
loadItemsOrTemplate,
propagateToSerie,
}
}
@@ -0,0 +1,103 @@
// src/features/agenda/composables/useFinancialExceptions.js
//
// CRUD sobre a tabela public.financial_exceptions.
//
// Interface pública:
// exceptions ref([]) lista de regras do owner (próprias + da clínica)
// loading ref(false)
// error ref('')
//
// load(ownerId) carrega registros do owner + regras globais (owner_id IS NULL)
// save(payload) cria ou atualiza (id presente = update dos campos editáveis)
// remove(id) hard delete (apenas registros do próprio owner)
import { ref } from 'vue'
import { supabase } from '@/lib/supabase/client'
export function useFinancialExceptions () {
const exceptions = ref([])
const loading = ref(false)
const error = ref('')
// ── Carregar exceções do owner + regras globais da clínica ───────────
async function load (ownerId) {
if (!ownerId) return
loading.value = true
error.value = ''
try {
const { data, error: err } = await supabase
.from('financial_exceptions')
.select('*')
.or(`owner_id.eq.${ownerId},owner_id.is.null`)
.order('exception_type', { ascending: true })
.order('created_at', { ascending: true })
if (err) throw err
exceptions.value = data || []
} catch (e) {
error.value = e?.message || 'Falha ao carregar exceções financeiras.'
exceptions.value = []
} finally {
loading.value = false
}
}
// ── Criar ou atualizar uma exceção ───────────────────────────────────
// Para UPDATE, apenas os campos editáveis são enviados:
// charge_mode, charge_value, charge_pct, min_hours_notice
// Regras globais (owner_id IS NULL) não devem ser editadas — o chamador
// é responsável por não chamar save() nesses registros.
async function save (payload) {
error.value = ''
try {
if (payload.id) {
const { error: err } = await supabase
.from('financial_exceptions')
.update({
charge_mode: payload.charge_mode,
charge_value: payload.charge_value ?? null,
charge_pct: payload.charge_pct ?? null,
min_hours_notice: payload.min_hours_notice ?? null,
})
.eq('id', payload.id)
if (err) throw err
} else {
const { error: err } = await supabase
.from('financial_exceptions')
.insert({
owner_id: payload.owner_id,
tenant_id: payload.tenant_id ?? null,
exception_type: payload.exception_type,
charge_mode: payload.charge_mode,
charge_value: payload.charge_value ?? null,
charge_pct: payload.charge_pct ?? null,
min_hours_notice: payload.min_hours_notice ?? null,
})
if (err) throw err
}
} catch (e) {
error.value = e?.message || 'Falha ao salvar exceção financeira.'
throw e
}
}
// ── Hard delete — apenas registros do próprio owner ──────────────────
// Regras globais (owner_id IS NULL) são protegidas pelo RLS do banco;
// a UI também deve esconder o botão de remover nesses casos.
async function remove (id) {
error.value = ''
try {
const { error: err } = await supabase
.from('financial_exceptions')
.delete()
.eq('id', id)
if (err) throw err
exceptions.value = exceptions.value.filter(e => e.id !== id)
} catch (e) {
error.value = e?.message || 'Falha ao remover exceção financeira.'
throw e
}
}
return { exceptions, loading, error, load, save, remove }
}
@@ -0,0 +1,83 @@
// src/features/agenda/composables/useInsurancePlans.js
//
// CRUD sobre a tabela public.insurance_plans.
//
// Interface pública:
// plans ref([]) lista de planos ativos do owner
// loading ref(false)
// error ref('')
//
// load(ownerId) carrega todos os planos ativos
// save(payload) cria ou atualiza (id presente = update)
// remove(id) soft-delete (active = false)
import { ref } from 'vue'
import { supabase } from '@/lib/supabase/client'
export function useInsurancePlans () {
const plans = ref([])
const loading = ref(false)
const error = ref('')
async function load (ownerId) {
if (!ownerId) return
loading.value = true
error.value = ''
try {
const { data, error: err } = await supabase
.from('insurance_plans')
.select('id, name, notes, default_value, active')
.eq('owner_id', ownerId)
.eq('active', true)
.order('name', { ascending: true })
if (err) throw err
plans.value = data || []
} catch (e) {
error.value = e?.message || 'Falha ao carregar convênios.'
plans.value = []
} finally {
loading.value = false
}
}
async function save (payload) {
error.value = ''
try {
if (payload.id) {
const { id, owner_id, tenant_id, ...fields } = payload
const { error: err } = await supabase
.from('insurance_plans')
.update(fields)
.eq('id', id)
.eq('owner_id', owner_id)
if (err) throw err
} else {
const { error: err } = await supabase
.from('insurance_plans')
.insert(payload)
if (err) throw err
}
} catch (e) {
error.value = e?.message || 'Falha ao salvar convênio.'
throw e
}
}
async function remove (id) {
error.value = ''
try {
const { error: err } = await supabase
.from('insurance_plans')
.update({ active: false })
.eq('id', id)
if (err) throw err
plans.value = plans.value.filter(p => p.id !== id)
} catch (e) {
error.value = e?.message || 'Falha ao remover convênio.'
throw e
}
}
return { plans, loading, error, load, save, remove }
}
@@ -0,0 +1,118 @@
// src/features/agenda/composables/usePatientDiscounts.js
//
// CRUD completo sobre a tabela public.patient_discounts.
//
// Interface pública:
// discounts ref([]) lista de descontos do owner
// loading ref(false)
// error ref('')
//
// load(ownerId) carrega todos os registros do owner
// save(payload) cria ou atualiza (id presente = update)
// remove(id) soft-delete (active = false)
// loadActive(ownerId, patientId) desconto ativo vigente para um paciente
import { ref } from 'vue'
import { supabase } from '@/lib/supabase/client'
export function usePatientDiscounts () {
const discounts = ref([])
const loading = ref(false)
const error = ref('')
// ── Carregar todos os descontos do owner ─────────────────────────────
async function load (ownerId) {
if (!ownerId) return
loading.value = true
error.value = ''
try {
const { data, error: err } = await supabase
.from('patient_discounts')
.select('*')
.eq('owner_id', ownerId)
.order('created_at', { ascending: false })
if (err) throw err
discounts.value = data || []
} catch (e) {
error.value = e?.message || 'Falha ao carregar descontos.'
discounts.value = []
} finally {
loading.value = false
}
}
// ── Criar ou atualizar um desconto ───────────────────────────────────
// payload deve conter: { owner_id, tenant_id, patient_id, discount_pct, discount_flat, ... }
// Se payload.id estiver presente, faz UPDATE; caso contrário, INSERT.
async function save (payload) {
error.value = ''
try {
if (payload.id) {
const { id, owner_id, tenant_id, ...fields } = payload
const { error: err } = await supabase
.from('patient_discounts')
.update(fields)
.eq('id', id)
.eq('owner_id', owner_id)
if (err) throw err
} else {
const { error: err } = await supabase
.from('patient_discounts')
.insert(payload)
if (err) throw err
}
} catch (e) {
error.value = e?.message || 'Falha ao salvar desconto.'
throw e
}
}
// ── Soft-delete: marca active = false ───────────────────────────────
async function remove (id) {
error.value = ''
try {
const { error: err } = await supabase
.from('patient_discounts')
.update({ active: false })
.eq('id', id)
if (err) throw err
discounts.value = discounts.value.filter(d => d.id !== id)
} catch (e) {
error.value = e?.message || 'Falha ao desativar desconto.'
throw e
}
}
// ── Desconto ativo vigente para um paciente específico ───────────────
// Retorna o primeiro registro que satisfaz:
// active = true
// active_from IS NULL OR active_from <= now()
// active_to IS NULL OR active_to >= now()
// Ordenado por created_at DESC (mais recente tem precedência).
async function loadActive (ownerId, patientId) {
if (!ownerId || !patientId) return null
try {
const now = new Date().toISOString()
const { data, error: err } = await supabase
.from('patient_discounts')
.select('*')
.eq('owner_id', ownerId)
.eq('patient_id', patientId)
.eq('active', true)
.or(`active_from.is.null,active_from.lte.${now}`)
.or(`active_to.is.null,active_to.gte.${now}`)
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle()
if (err) throw err
return data || null
} catch (e) {
console.warn('[usePatientDiscounts] loadActive error:', e?.message)
return null
}
}
return { discounts, loading, error, load, save, remove, loadActive }
}
@@ -346,7 +346,12 @@ export function mergeWithStoredSessions (occurrences, storedRows) {
for (const row of storedRows || []) {
if (!row.recurrence_id || !row.recurrence_date) continue
const key = `${row.recurrence_id}::${row.recurrence_date}`
realMap.set(key, { ...row, is_real_session: true, is_occurrence: false })
realMap.set(key, {
...row,
is_real_session: true,
is_occurrence: false,
original_date: row.original_date ?? row.recurrence_date ?? null,
})
}
const result = []
@@ -0,0 +1,109 @@
// src/features/agenda/composables/useServices.js
//
// CRUD completo sobre a tabela public.services.
//
// Interface pública:
// services ref([]) lista de serviços ativos do owner
// loading ref(false)
// error ref('')
//
// load(ownerId) carrega todos os serviços ativos
// save(payload) cria ou atualiza (id presente = update)
// remove(id) soft-delete (active = false)
// getDefaultPrice() preço do primeiro serviço ativo, ou null
// getPriceFor(serviceId) preço de um serviço específico, ou null
import { ref } from 'vue'
import { supabase } from '@/lib/supabase/client'
export function useServices () {
const services = ref([])
const loading = ref(false)
const error = ref('')
// ── Carregar serviços ativos do owner ───────────────────────────────
async function load (ownerId) {
if (!ownerId) return
loading.value = true
error.value = ''
try {
const { data, error: err } = await supabase
.from('services')
.select('id, name, description, price, duration_min, active')
.eq('owner_id', ownerId)
.eq('active', true)
.order('created_at', { ascending: true })
if (err) throw err
services.value = data || []
} catch (e) {
error.value = e?.message || 'Falha ao carregar serviços.'
services.value = []
} finally {
loading.value = false
}
}
// ── Criar ou atualizar um serviço ───────────────────────────────────
// payload deve conter: { owner_id, tenant_id, name, price, description?, duration_min? }
// Se payload.id estiver presente, faz UPDATE; caso contrário, INSERT.
async function save (payload) {
error.value = ''
try {
if (payload.id) {
const { id, owner_id, tenant_id, ...fields } = payload
const { error: err } = await supabase
.from('services')
.update(fields)
.eq('id', id)
.eq('owner_id', owner_id)
if (err) throw err
} else {
const { error: err } = await supabase
.from('services')
.insert(payload)
if (err) throw err
}
} catch (e) {
error.value = e?.message || 'Falha ao salvar serviço.'
throw e
}
}
// ── Soft-delete: marca active = false ───────────────────────────────
async function remove (id) {
error.value = ''
try {
const { error: err } = await supabase
.from('services')
.update({ active: false })
.eq('id', id)
if (err) throw err
services.value = services.value.filter(s => s.id !== id)
} catch (e) {
error.value = e?.message || 'Falha ao remover serviço.'
throw e
}
}
// ── Helpers de preço ────────────────────────────────────────────────
// Retorna o preço de um serviço específico (serviceId fornecido) ou
// o preço do primeiro serviço ativo da lista (serviceId omitido).
// Retorna null se não houver serviços ou o id não for encontrado.
function getDefaultPrice (serviceId) {
if (serviceId) {
const svc = services.value.find(s => s.id === serviceId)
return svc?.price != null ? Number(svc.price) : null
}
const first = services.value[0]
return first?.price != null ? Number(first.price) : null
}
// Alias explícito para clareza nos chamadores que conhecem o id
function getPriceFor (serviceId) {
return getDefaultPrice(serviceId)
}
return { services, loading, error, load, save, remove, getDefaultPrice, getPriceFor }
}
+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);
+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;
@@ -116,7 +116,10 @@ function _mapRow (r) {
is_exception: r.is_exception ?? (exceptionType != null),
// financeiro
price: r.price ?? null,
price: 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,
// timestamps
inicio_em: r.inicio_em,
+102 -6
View File
@@ -31,7 +31,8 @@
<!-- Controles desktop (1200px) -->
<div class="hidden xl:flex items-center gap-2 shrink-0">
<Button icon="pi pi-refresh" severity="secondary" outlined class="h-9 w-9 rounded-full" :loading="loading" title="Atualizar" @click="fetchAll" />
<SplitButton label="Cadastrar" icon="pi pi-user-plus" :model="createMenu" class="rounded-full" @click="goCreateFull" />
<Button icon="pi pi-percentage" severity="secondary" outlined class="h-9 w-9 rounded-full" title="Descontos por Paciente" @click="router.push('/configuracoes/descontos')" />
<SplitButton label="Novo" icon="pi pi-user-plus" :model="createMenu" class="rounded-full" @click="goCreateFull" />
</div>
<!-- Menu mobile (<1200px) -->
@@ -413,7 +414,23 @@
<Avatar v-if="data.avatar_url" :image="data.avatar_url" shape="square" size="large" />
<Avatar v-else :label="initials(data.nome_completo)" shape="square" size="large" />
<div class="min-w-0">
<div class="font-medium truncate">{{ data.nome_completo }}</div>
<div class="flex items-center gap-1.5">
<span class="font-medium truncate">{{ data.nome_completo }}</span>
<Tag
v-if="discountMap[data.id]"
:value="fmtDiscount(discountMap[data.id])"
severity="success"
class="shrink-0"
style="font-size: 0.7rem; padding: 1px 6px;"
/>
<Tag
v-if="insuranceMap[data.id]"
:value="insuranceMap[data.id]"
severity="info"
class="shrink-0"
style="font-size: 0.7rem; padding: 1px 6px;"
/>
</div>
<small class="text-color-secondary">ID: {{ shortId(data.id) }}</small>
</div>
</div>
@@ -509,7 +526,23 @@
<Avatar v-if="pat.avatar_url" :image="pat.avatar_url" shape="square" size="large" />
<Avatar v-else :label="initials(pat.nome_completo)" shape="square" size="large" />
<div class="flex-1 min-w-0">
<div class="font-semibold truncate">{{ pat.nome_completo }}</div>
<div class="flex items-center gap-1.5">
<span class="font-semibold truncate">{{ pat.nome_completo }}</span>
<Tag
v-if="discountMap[pat.id]"
:value="fmtDiscount(discountMap[pat.id])"
severity="success"
class="shrink-0"
style="font-size: 0.7rem; padding: 1px 6px;"
/>
<Tag
v-if="insuranceMap[pat.id]"
:value="insuranceMap[pat.id]"
severity="info"
class="shrink-0"
style="font-size: 0.7rem; padding: 1px 6px;"
/>
</div>
<div class="text-xs text-color-secondary">{{ fmtPhoneBR(pat.telefone) }} · {{ pat.email_principal || '—' }}</div>
</div>
<Tag :value="pat.status" :severity="pat.status === 'Ativo' ? 'success' : 'danger'" />
@@ -632,10 +665,15 @@
<div v-else class="sess-list">
<div v-for="ev in sessoesLista" :key="ev.id" class="sess-item">
<div class="flex items-center gap-3">
<div class="flex items-center gap-3 flex-wrap">
<Tag :value="ev.status || 'agendado'" :severity="statusSessaoSev(ev.status)" />
<span class="font-semibold text-sm">{{ fmtDataSessao(ev.inicio_em) }}</span>
<Tag v-if="ev.modalidade" :value="ev.modalidade" severity="secondary" class="ml-auto" />
<span v-if="ev.insurance_plans?.name" class="text-xs text-color-secondary flex items-center gap-1">
<i class="pi pi-id-card opacity-60" />{{ ev.insurance_plans.name }}
<span v-if="ev.insurance_guide_number" class="opacity-70">· Guia: {{ ev.insurance_guide_number }}</span>
<span v-if="ev.insurance_value" class="opacity-70">· {{ fmtBRL(ev.insurance_value) }}</span>
</span>
</div>
<div v-if="ev.titulo" class="text-xs text-color-secondary mt-1">{{ ev.titulo }}</div>
</div>
@@ -655,6 +693,14 @@ import Popover from 'primevue/popover'
import Menu from 'primevue/menu'
import ProgressSpinner from 'primevue/progressspinner'
// ── Descontos por paciente ────────────────────────────────────────
// Map de patient_id → { discount_pct, discount_flat }
const discountMap = ref({})
// ── Convênio por paciente ─────────────────────────────────────────
// Map de patient_id → nome do convênio mais recente
const insuranceMap = ref({})
import PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue'
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue'
@@ -686,7 +732,7 @@ async function abrirSessoes (pat) {
const [evts, recs] = await Promise.all([
supabase
.from('agenda_eventos')
.select('id, titulo, tipo, status, inicio_em, fim_em, modalidade')
.select('id, titulo, tipo, status, inicio_em, fim_em, modalidade, insurance_guide_number, insurance_value, insurance_plans(name)')
.eq('patient_id', pat.id)
.order('inicio_em', { ascending: false })
.limit(100),
@@ -746,6 +792,8 @@ const patMobileMenuItems = [
{ label: 'Cadastro Rápido', icon: 'pi pi-bolt', command: () => openQuickCreate() },
{ label: 'Cadastro Completo', icon: 'pi pi-file-edit', command: () => goCreateFull() },
{ separator: true },
{ label: 'Descontos por Paciente', icon: 'pi pi-percentage', command: () => router.push('/configuracoes/descontos') },
{ separator: true },
{ label: 'Atualizar', icon: 'pi pi-refresh', command: () => fetchAll() }
]
@@ -870,6 +918,19 @@ onMounted(async () => {
onBeforeUnmount(() => { _observer?.disconnect() })
function fmtBRL (v) {
if (v == null || v === '') return '—'
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
}
function fmtDiscount (d) {
if (!d) return null
const parts = []
if (Number(d.discount_pct) > 0) parts.push(`${Number(d.discount_pct)}%`)
if (Number(d.discount_flat) > 0) parts.push(fmtBRL(d.discount_flat))
return parts.length ? parts.join(' + ') : null
}
function fmtPhoneBR(v) {
const d = String(v ?? '').replace(/\D/g, '')
if (!d) return '—'
@@ -1144,6 +1205,41 @@ async function fetchAll() {
const base = await listPatients()
patients.value = base
// Carrega descontos ativos de todos os pacientes de uma vez
discountMap.value = {}
if (uid.value) {
const now = new Date().toISOString()
const { data: discRows } = await supabase
.from('patient_discounts')
.select('patient_id, discount_pct, discount_flat')
.eq('owner_id', uid.value)
.eq('active', true)
.or(`active_to.is.null,active_to.gte.${now}`)
if (discRows) {
discountMap.value = Object.fromEntries(
discRows.map(d => [d.patient_id, d])
)
}
}
// Carrega convênio mais recente por paciente
insuranceMap.value = {}
if (uid.value) {
const { data: insRows } = await supabase
.from('agenda_eventos')
.select('patient_id, insurance_plan_id, insurance_plans(name)')
.eq('owner_id', uid.value)
.not('insurance_plan_id', 'is', null)
.order('inicio_em', { ascending: false })
if (insRows) {
for (const row of insRows) {
if (!insuranceMap.value[row.patient_id]) {
insuranceMap.value[row.patient_id] = row.insurance_plans?.name ?? null
}
}
}
}
groups.value = await listGroups()
console.log('[PatientsListPage] groups loaded:', groups.value)
@@ -1459,4 +1555,4 @@ function updateKpis() {
background: var(--surface-ground);
font-size: .85rem;
}
</style>
</style>