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
@@ -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 }
}