Preficicação, Convenio, Ajustes Agenda, Configurações Excessões
This commit is contained in:
@@ -11,7 +11,11 @@
|
||||
"Bash(find \"D:\\\\leonohama\\\\AgenciaPsi.com.br\\\\Sistema\\\\agenciapsi-primesakai/DBS\" -name \"*.sql\" -type f 2>/dev/null | xargs grep -l \"agenda_eventos\" | head -3)",
|
||||
"Bash(find \"D:\\\\leonohama\\\\AgenciaPsi.com.br\\\\Sistema\\\\agenciapsi-primesakai/DBS/2026-03-11\" -name \"*.sql\" -type f 2>/dev/null | head -3)",
|
||||
"Bash(find \"D:\\\\leonohama\\\\AgenciaPsi.com.br\\\\Sistema\\\\agenciapsi-primesakai\" -type f -name \"*.sql\" 2>/dev/null | xargs grep -l \"agenda_eventos\" 2>/dev/null | head -5)",
|
||||
"Bash(find /d/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/src -name \"*[Pp]ricing*\" -o -name \"*[Pp]reco*\" -o -name \"*[Vv]alor*\" 2>/dev/null | head -20)"
|
||||
"Bash(find /d/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/src -name \"*[Pp]ricing*\" -o -name \"*[Pp]reco*\" -o -name \"*[Vv]alor*\" 2>/dev/null | head -20)",
|
||||
"Bash(where python:*)",
|
||||
"Bash(cd \"/d/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai\" && C:/Users/lmnohama/AppData/Local/Programs/Python/Python310/python.exe -c \"\nimport zipfile\nimport xml.etree.ElementTree as ET\n\nfor fname in ['spec-wizard.docx', 'spec-v2.docx']:\n print\\('=== ' + fname + ' ==='\\)\n try:\n with zipfile.ZipFile\\(fname, 'r'\\) as z:\n with z.open\\('word/document.xml'\\) as f:\n tree = ET.parse\\(f\\)\n root = tree.getroot\\(\\)\n texts = []\n for para in root.iter\\('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}p'\\):\n parts = []\n for t in para.iter\\('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t'\\):\n if t.text:\n parts.append\\(t.text\\)\n line = ''.join\\(parts\\)\n texts.append\\(line\\)\n print\\('\\\\n'.join\\(texts\\)\\)\n except Exception as e:\n print\\('Error: ' + str\\(e\\)\\)\n print\\(\\)\n\")",
|
||||
"Bash(C:/Users/lmnohama/AppData/Local/Programs/Python/Python310/python.exe -c \"\nimport sys\nwith open\\('/d/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/DBS/2026-03-12/schema.sql', 'r', encoding='utf-8'\\) as f:\n lines = f.readlines\\(\\)\nprint\\(f'Total lines: {len\\(lines\\)}'\\)\n\" 2>&1)",
|
||||
"Bash(C:/Users/lmnohama/AppData/Local/Programs/Python/Python310/python.exe -c \"\nimport sys\nfpath = 'D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/DBS/2026-03-12/schema.sql'\nwith open\\(fpath, 'r', encoding='utf-8'\\) as f:\n lines = f.readlines\\(\\)\nsys.stdout.buffer.write\\(\\('Total lines: ' + str\\(len\\(lines\\)\\) + '\\\\n'\\).encode\\('utf-8'\\)\\)\n\" 2>&1)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(cd \"D:\\\\leonohama\\\\AgenciaPsi.com.br\\\\Sistema\\\\agenciapsi-primesakai\" && python -c \"\nimport zipfile, xml.etree.ElementTree as ET, sys\nsys.stdout.reconfigure\\(encoding='utf-8'\\)\n\nwith zipfile.ZipFile\\('spec-wizard.docx', 'r'\\) as z:\n with z.open\\('word/document.xml'\\) as f:\n tree = ET.parse\\(f\\)\n root = tree.getroot\\(\\)\n texts = []\n for para in root.iter\\('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}p'\\):\n line = ''\n for t in para.iter\\('{http://schemas.openxmlformats.org/wordprocessingml/2006/main}t'\\):\n if t.text:\n line += t.text\n texts.append\\(line\\)\n print\\('\\\\n'.join\\(texts\\)\\)\n\" 2>&1)",
|
||||
"Bash(find \"D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/src\" -type f -name \"*agenda*\" | grep -E \"\\\\.\\(js|vue\\)$\" | head -30)",
|
||||
"Bash(find \"D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/src\" -type f \\\\\\( -name \"*.js\" -o -name \"*.vue\" \\\\\\) | xargs grep -l \"price\\\\|professional.*pricing\\\\|preço\\\\|valor\" 2>/dev/null | head -40)",
|
||||
"Bash(find \"D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/src\" -type f \\\\\\( -name \"*.js\" -o -name \"*.vue\" \\\\\\) | xargs grep -l \"duration\\\\|session_duration\\\\|session.*min\" 2>/dev/null | head -40)",
|
||||
"Bash(grep -rn \"\\\\.price\\\\|form\\\\.price\\\\|event\\\\.price\" \"D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/src\" --include=\"*.js\" --include=\"*.vue\" 2>/dev/null | head -40)",
|
||||
"Bash(grep -rn \"professional_pricing\\\\|determined_commitment\" \"D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/src\" --include=\"*.js\" --include=\"*.vue\" 2>/dev/null | head -40)",
|
||||
"Bash(grep -rn \"toLocaleString\\\\|fmtBRL\" \"D:/leonohama/AgenciaPsi.com.br/Sistema/agenciapsi-primesakai/src\" --include=\"*.js\" --include=\"*.vue\" 2>/dev/null | grep -i \"currency\\\\|brl\\\\|price\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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 — só 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 (só 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">Nº 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 (só 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 (só quando Pago e há 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, só quando há 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 }
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -53,6 +53,30 @@ const secoes = [
|
||||
to: '/configuracoes/precificacao',
|
||||
tags: ['Valores', 'Sessão', 'Compromisso']
|
||||
},
|
||||
{
|
||||
key: 'descontos',
|
||||
label: 'Descontos por Paciente',
|
||||
desc: 'Descontos recorrentes aplicados automaticamente por paciente.',
|
||||
icon: 'pi pi-percentage',
|
||||
to: '/configuracoes/descontos',
|
||||
tags: ['Desconto', 'Paciente', 'Automático']
|
||||
},
|
||||
{
|
||||
key: 'excecoes-financeiras',
|
||||
label: 'Exceções Financeiras',
|
||||
desc: 'O que cobrar em faltas, cancelamentos e outras situações excepcionais.',
|
||||
icon: 'pi pi-exclamation-triangle',
|
||||
to: '/configuracoes/excecoes-financeiras',
|
||||
tags: ['Falta', 'Cancelamento', 'Cobrança']
|
||||
},
|
||||
{
|
||||
key: 'convenios',
|
||||
label: 'Convênios',
|
||||
desc: 'Cadastre os convênios que você atende e seus valores de tabela.',
|
||||
icon: 'pi pi-id-card',
|
||||
to: '/configuracoes/convenios',
|
||||
tags: ['Convênio', 'Plano de Saúde', 'Tabela']
|
||||
},
|
||||
|
||||
// Ative quando criar as rotas/páginas
|
||||
// {
|
||||
@@ -248,4 +272,4 @@ onBeforeUnmount(() => { _observer?.disconnect() })
|
||||
}
|
||||
.cfg-hero__title { font-size: 1.1rem; font-weight: 700; letter-spacing: -0.02em; color: var(--text-color); }
|
||||
.cfg-hero__sub { font-size: 0.78rem; color: var(--text-color-secondary); margin-top: 2px; }
|
||||
</style>
|
||||
</style>
|
||||
@@ -0,0 +1,343 @@
|
||||
<!-- src/layout/configuracoes/ConfiguracoesConveniosPage.vue -->
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
|
||||
const toast = useToast()
|
||||
const tenantStore = useTenantStore()
|
||||
|
||||
const ownerId = ref(null)
|
||||
const tenantId = ref(null)
|
||||
const pageLoading = ref(true)
|
||||
const plans = ref([])
|
||||
|
||||
// ── Formulário ───────────────────────────────────────────────────────
|
||||
const emptyForm = () => ({ name: '', notes: '', default_value: null })
|
||||
|
||||
const newForm = ref(emptyForm())
|
||||
const addingNew = ref(false)
|
||||
const savingNew = ref(false)
|
||||
|
||||
const editingId = ref(null)
|
||||
const editForm = ref({})
|
||||
const savingEdit = ref(false)
|
||||
|
||||
// ── Load ─────────────────────────────────────────────────────────────
|
||||
async function load (uid) {
|
||||
const { data, error } = await supabase
|
||||
.from('insurance_plans')
|
||||
.select('*')
|
||||
.eq('owner_id', uid)
|
||||
.eq('active', true)
|
||||
.order('name')
|
||||
if (error) throw error
|
||||
plans.value = data || []
|
||||
}
|
||||
|
||||
// ── Save ─────────────────────────────────────────────────────────────
|
||||
async function savePlan (payload) {
|
||||
if (payload.id) {
|
||||
const { error } = await supabase
|
||||
.from('insurance_plans')
|
||||
.update({
|
||||
name: payload.name.trim(),
|
||||
notes: payload.notes?.trim() || null,
|
||||
default_value: payload.default_value ?? null,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', payload.id)
|
||||
if (error) throw error
|
||||
} else {
|
||||
const { error } = await supabase
|
||||
.from('insurance_plans')
|
||||
.insert({
|
||||
owner_id: ownerId.value,
|
||||
tenant_id: tenantId.value,
|
||||
name: payload.name.trim(),
|
||||
notes: payload.notes?.trim() || null,
|
||||
default_value: payload.default_value ?? null,
|
||||
})
|
||||
if (error) throw error
|
||||
}
|
||||
}
|
||||
|
||||
// ── Remove (soft-delete) ─────────────────────────────────────────────
|
||||
async function removePlan (id) {
|
||||
const { error } = await supabase
|
||||
.from('insurance_plans')
|
||||
.update({ active: false })
|
||||
.eq('id', id)
|
||||
if (error) throw error
|
||||
plans.value = plans.value.filter(p => p.id !== id)
|
||||
toast.add({ severity: 'success', summary: 'Removido', detail: 'Convênio desativado.', life: 3000 })
|
||||
}
|
||||
|
||||
// ── Edit helpers ──────────────────────────────────────────────────────
|
||||
function startEdit (plan) {
|
||||
editingId.value = plan.id
|
||||
editForm.value = {
|
||||
id: plan.id,
|
||||
name: plan.name,
|
||||
notes: plan.notes ?? '',
|
||||
default_value: plan.default_value != null ? Number(plan.default_value) : null,
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEdit () {
|
||||
editingId.value = null
|
||||
editForm.value = {}
|
||||
}
|
||||
|
||||
async function saveEdit () {
|
||||
if (!editForm.value.name?.trim()) {
|
||||
toast.add({ severity: 'warn', summary: 'Campo obrigatório', detail: 'Nome é obrigatório.', life: 3000 })
|
||||
return
|
||||
}
|
||||
savingEdit.value = true
|
||||
try {
|
||||
await savePlan(editForm.value)
|
||||
await load(ownerId.value)
|
||||
cancelEdit()
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Convênio atualizado.', life: 3000 })
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao salvar.', life: 4000 })
|
||||
} finally {
|
||||
savingEdit.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveNew () {
|
||||
if (!newForm.value.name?.trim()) {
|
||||
toast.add({ severity: 'warn', summary: 'Campo obrigatório', detail: 'Nome é obrigatório.', life: 3000 })
|
||||
return
|
||||
}
|
||||
savingNew.value = true
|
||||
try {
|
||||
await savePlan(newForm.value)
|
||||
await load(ownerId.value)
|
||||
newForm.value = emptyForm()
|
||||
addingNew.value = false
|
||||
toast.add({ severity: 'success', summary: 'Adicionado', detail: 'Convênio criado com sucesso.', life: 3000 })
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao criar.', life: 4000 })
|
||||
} finally {
|
||||
savingNew.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function fmtBRL (v) {
|
||||
if (v == null || v === '') return '—'
|
||||
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
|
||||
}
|
||||
|
||||
// ── Mount ─────────────────────────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const uid = tenantStore.user?.id || (await supabase.auth.getUser()).data?.user?.id
|
||||
if (!uid) return
|
||||
ownerId.value = uid
|
||||
tenantId.value = tenantStore.activeTenantId || null
|
||||
await load(uid)
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar.', life: 4000 })
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Toast />
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<!-- Header -->
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="cfg-icon-box">
|
||||
<i class="pi pi-id-card text-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-900 font-semibold text-lg">Convênios</div>
|
||||
<div class="text-600 text-sm">
|
||||
Cadastre os convênios que você atende e seus valores de tabela.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
label="Novo convênio"
|
||||
icon="pi pi-plus"
|
||||
:disabled="pageLoading || addingNew"
|
||||
@click="addingNew = true; cancelEdit()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="pageLoading" class="flex justify-center py-10">
|
||||
<ProgressSpinner style="width:40px;height:40px" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
|
||||
<!-- Formulário novo convênio -->
|
||||
<Card v-if="addingNew">
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-plus-circle text-primary-500" />
|
||||
<span>Novo convênio</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="grid grid-cols-12 gap-3">
|
||||
<div class="col-span-12 sm:col-span-4">
|
||||
<FloatLabel variant="on">
|
||||
<InputText v-model="newForm.name" inputId="new-name" class="w-full" />
|
||||
<label for="new-name">Nome *</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div class="col-span-12 sm:col-span-4">
|
||||
<FloatLabel variant="on">
|
||||
<InputNumber
|
||||
v-model="newForm.default_value"
|
||||
inputId="new-value"
|
||||
mode="currency"
|
||||
currency="BRL"
|
||||
locale="pt-BR"
|
||||
:min="0"
|
||||
:minFractionDigits="2"
|
||||
fluid
|
||||
/>
|
||||
<label for="new-value">Valor padrão da tabela (R$)</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div class="col-span-12 sm:col-span-4">
|
||||
<FloatLabel variant="on">
|
||||
<InputText v-model="newForm.notes" inputId="new-notes" class="w-full" />
|
||||
<label for="new-notes">Observações (opcional)</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end mt-4">
|
||||
<Button label="Cancelar" severity="secondary" outlined @click="addingNew = false; newForm = emptyForm()" />
|
||||
<Button label="Salvar" icon="pi pi-check" :loading="savingNew" @click="saveNew" />
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Lista vazia -->
|
||||
<Card v-if="!plans.length && !addingNew">
|
||||
<template #content>
|
||||
<div class="text-center py-6 text-color-secondary">
|
||||
<i class="pi pi-id-card text-4xl opacity-30 mb-3 block" />
|
||||
<div class="font-medium mb-1">Nenhum convênio cadastrado</div>
|
||||
<div class="text-sm">Clique em "Novo convênio" para começar.</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Lista de convênios -->
|
||||
<Card v-for="plan in plans" :key="plan.id">
|
||||
<template #content>
|
||||
|
||||
<!-- Modo edição -->
|
||||
<template v-if="editingId === plan.id">
|
||||
<div class="grid grid-cols-12 gap-3">
|
||||
<div class="col-span-12 sm:col-span-4">
|
||||
<FloatLabel variant="on">
|
||||
<InputText v-model="editForm.name" :inputId="`edit-name-${plan.id}`" class="w-full" />
|
||||
<label :for="`edit-name-${plan.id}`">Nome *</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div class="col-span-12 sm:col-span-4">
|
||||
<FloatLabel variant="on">
|
||||
<InputNumber
|
||||
v-model="editForm.default_value"
|
||||
:inputId="`edit-value-${plan.id}`"
|
||||
mode="currency"
|
||||
currency="BRL"
|
||||
locale="pt-BR"
|
||||
:min="0"
|
||||
:minFractionDigits="2"
|
||||
fluid
|
||||
/>
|
||||
<label :for="`edit-value-${plan.id}`">Valor padrão da tabela (R$)</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div class="col-span-12 sm:col-span-4">
|
||||
<FloatLabel variant="on">
|
||||
<InputText v-model="editForm.notes" :inputId="`edit-notes-${plan.id}`" class="w-full" />
|
||||
<label :for="`edit-notes-${plan.id}`">Observações (opcional)</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end mt-4">
|
||||
<Button label="Cancelar" severity="secondary" outlined @click="cancelEdit" />
|
||||
<Button label="Salvar" icon="pi pi-check" :loading="savingEdit" @click="saveEdit" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Modo leitura -->
|
||||
<template v-else>
|
||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="cfg-icon-box-sm">
|
||||
<i class="pi pi-id-card" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-900">{{ plan.name }}</div>
|
||||
<div class="text-sm text-color-secondary flex flex-wrap gap-x-3 gap-y-0.5 mt-0.5">
|
||||
<span><b class="text-primary-500">{{ fmtBRL(plan.default_value) }}</b></span>
|
||||
<span v-if="plan.notes" class="italic">{{ plan.notes }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Tag value="Ativo" severity="success" />
|
||||
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" v-tooltip.top="'Editar'" @click="startEdit(plan)" />
|
||||
<Button icon="pi pi-trash" severity="danger" outlined size="small" v-tooltip.top="'Desativar'" @click="removePlan(plan.id)" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<Message severity="info" :closable="false">
|
||||
<span class="text-sm">
|
||||
O convênio selecionado em um evento preenche automaticamente o valor da sessão com o valor de tabela cadastrado aqui.
|
||||
</span>
|
||||
</Message>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cfg-icon-box {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 0.875rem;
|
||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
|
||||
color: var(--p-primary-500, #6366f1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cfg-icon-box-sm {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.625rem;
|
||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 10%, transparent);
|
||||
color: var(--p-primary-500, #6366f1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,590 @@
|
||||
<!-- src/layout/configuracoes/ConfiguracoesDescontosPage.vue -->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { usePatientDiscounts } from '@/features/agenda/composables/usePatientDiscounts'
|
||||
|
||||
const toast = useToast()
|
||||
const tenantStore = useTenantStore()
|
||||
|
||||
const { discounts, loading, error: discountsError, load, save, remove } = usePatientDiscounts()
|
||||
|
||||
const ownerId = ref(null)
|
||||
const tenantId = ref(null)
|
||||
const pageLoading = ref(true)
|
||||
const patients = ref([])
|
||||
|
||||
// ── Formulário ────────────────────────────────────────────────────────
|
||||
const emptyForm = () => ({
|
||||
patient_id: null,
|
||||
discount_pct: 0,
|
||||
discount_flat: 0,
|
||||
reason: '',
|
||||
active_from: null,
|
||||
active_to: null,
|
||||
})
|
||||
|
||||
const newForm = ref(emptyForm())
|
||||
const addingNew = ref(false)
|
||||
const savingNew = ref(false)
|
||||
|
||||
// ── Edição inline ─────────────────────────────────────────────────────
|
||||
const editingId = ref(null)
|
||||
const editForm = ref({})
|
||||
const savingEdit = ref(false)
|
||||
|
||||
// ── Lookup de nome do paciente ────────────────────────────────────────
|
||||
const patientMap = computed(() => {
|
||||
const map = {}
|
||||
for (const p of patients.value) map[p.id] = p.nome_completo
|
||||
return map
|
||||
})
|
||||
|
||||
function patientName (pid) {
|
||||
return patientMap.value[pid] || pid || '—'
|
||||
}
|
||||
|
||||
// ── Editar ────────────────────────────────────────────────────────────
|
||||
function startEdit (disc) {
|
||||
editingId.value = disc.id
|
||||
editForm.value = {
|
||||
id: disc.id,
|
||||
owner_id: ownerId.value,
|
||||
tenant_id: tenantId.value,
|
||||
patient_id: disc.patient_id,
|
||||
discount_pct: disc.discount_pct != null ? Number(disc.discount_pct) : 0,
|
||||
discount_flat: disc.discount_flat != null ? Number(disc.discount_flat) : 0,
|
||||
reason: disc.reason ?? '',
|
||||
active_from: disc.active_from ? new Date(disc.active_from) : null,
|
||||
active_to: disc.active_to ? new Date(disc.active_to) : null,
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEdit () {
|
||||
editingId.value = null
|
||||
editForm.value = {}
|
||||
}
|
||||
|
||||
async function saveEdit () {
|
||||
if (!editForm.value.discount_pct && !editForm.value.discount_flat) {
|
||||
toast.add({ severity: 'warn', summary: 'Campos obrigatórios', detail: 'Informe ao menos um desconto (% ou R$).', life: 3000 })
|
||||
return
|
||||
}
|
||||
savingEdit.value = true
|
||||
try {
|
||||
await save({
|
||||
...editForm.value,
|
||||
discount_pct: editForm.value.discount_pct ?? 0,
|
||||
discount_flat: editForm.value.discount_flat ?? 0,
|
||||
reason: editForm.value.reason?.trim() || null,
|
||||
active_from: editForm.value.active_from ? editForm.value.active_from.toISOString().slice(0, 10) : null,
|
||||
active_to: editForm.value.active_to ? editForm.value.active_to.toISOString().slice(0, 10) : null,
|
||||
})
|
||||
await load(ownerId.value)
|
||||
cancelEdit()
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Desconto atualizado.', life: 3000 })
|
||||
} catch {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: discountsError.value || 'Falha ao salvar.', life: 4000 })
|
||||
} finally {
|
||||
savingEdit.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Novo desconto ─────────────────────────────────────────────────────
|
||||
async function saveNew () {
|
||||
if (!newForm.value.patient_id) {
|
||||
toast.add({ severity: 'warn', summary: 'Campo obrigatório', detail: 'Selecione um paciente.', life: 3000 })
|
||||
return
|
||||
}
|
||||
if (!newForm.value.discount_pct && !newForm.value.discount_flat) {
|
||||
toast.add({ severity: 'warn', summary: 'Campos obrigatórios', detail: 'Informe ao menos um desconto (% ou R$).', life: 3000 })
|
||||
return
|
||||
}
|
||||
savingNew.value = true
|
||||
try {
|
||||
await save({
|
||||
owner_id: ownerId.value,
|
||||
tenant_id: tenantId.value,
|
||||
patient_id: newForm.value.patient_id,
|
||||
discount_pct: newForm.value.discount_pct ?? 0,
|
||||
discount_flat: newForm.value.discount_flat ?? 0,
|
||||
reason: newForm.value.reason?.trim() || null,
|
||||
active_from: newForm.value.active_from ? newForm.value.active_from.toISOString().slice(0, 10) : null,
|
||||
active_to: newForm.value.active_to ? newForm.value.active_to.toISOString().slice(0, 10) : null,
|
||||
active: true,
|
||||
})
|
||||
await load(ownerId.value)
|
||||
newForm.value = emptyForm()
|
||||
addingNew.value = false
|
||||
toast.add({ severity: 'success', summary: 'Adicionado', detail: 'Desconto criado com sucesso.', life: 3000 })
|
||||
} catch {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: discountsError.value || 'Falha ao criar.', life: 4000 })
|
||||
} finally {
|
||||
savingNew.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Desativar (soft-delete) ───────────────────────────────────────────
|
||||
async function confirmRemove (id) {
|
||||
try {
|
||||
await remove(id)
|
||||
toast.add({ severity: 'success', summary: 'Desativado', detail: 'Desconto desativado.', life: 3000 })
|
||||
} catch {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: discountsError.value || 'Falha ao desativar.', life: 4000 })
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers de exibição ───────────────────────────────────────────────
|
||||
function fmtBRL (v) {
|
||||
if (v == null || v === '' || Number(v) === 0) return null
|
||||
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
|
||||
}
|
||||
|
||||
function fmtPct (v) {
|
||||
if (v == null || Number(v) === 0) return null
|
||||
return `${Number(v).toLocaleString('pt-BR', { minimumFractionDigits: 0, maximumFractionDigits: 2 })}%`
|
||||
}
|
||||
|
||||
function fmtDate (v) {
|
||||
if (!v) return null
|
||||
const d = new Date(v)
|
||||
return d.toLocaleDateString('pt-BR')
|
||||
}
|
||||
|
||||
// ── Mount ─────────────────────────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const uid = tenantStore.user?.id || (await supabase.auth.getUser()).data?.user?.id
|
||||
if (!uid) return
|
||||
|
||||
ownerId.value = uid
|
||||
tenantId.value = tenantStore.activeTenantId || null
|
||||
|
||||
const [, { data: pData }] = await Promise.all([
|
||||
load(uid),
|
||||
supabase
|
||||
.from('patients')
|
||||
.select('id, nome_completo')
|
||||
.eq('owner_id', uid)
|
||||
.eq('status', 'Ativo')
|
||||
.order('nome_completo', { ascending: true }),
|
||||
])
|
||||
|
||||
patients.value = pData || []
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar.', life: 4000 })
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Toast />
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<!-- Header -->
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="cfg-icon-box">
|
||||
<i class="pi pi-percentage text-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-900 font-semibold text-lg">Descontos por Paciente</div>
|
||||
<div class="text-600 text-sm">
|
||||
Configure descontos recorrentes aplicados automaticamente por paciente.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
label="Novo desconto"
|
||||
icon="pi pi-plus"
|
||||
:disabled="pageLoading || addingNew"
|
||||
@click="addingNew = true; cancelEdit()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="pageLoading || loading" class="flex justify-center py-10">
|
||||
<ProgressSpinner style="width:40px;height:40px" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
|
||||
<!-- Lista de descontos -->
|
||||
<Card v-if="discounts.length || addingNew">
|
||||
<template #content>
|
||||
<div class="flex flex-col gap-3">
|
||||
|
||||
<template v-for="disc in discounts" :key="disc.id">
|
||||
|
||||
<!-- Modo edição inline -->
|
||||
<div v-if="editingId === disc.id" class="discount-row editing">
|
||||
<div class="grid grid-cols-12 gap-3 flex-1">
|
||||
|
||||
<!-- Paciente (desabilitado na edição) -->
|
||||
<div class="col-span-12 sm:col-span-4">
|
||||
<FloatLabel variant="on">
|
||||
<Select
|
||||
v-model="editForm.patient_id"
|
||||
inputId="edit-patient"
|
||||
:options="patients"
|
||||
optionLabel="nome_completo"
|
||||
optionValue="id"
|
||||
disabled
|
||||
class="w-full"
|
||||
/>
|
||||
<label for="edit-patient">Paciente</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<!-- Desconto % -->
|
||||
<div class="col-span-6 sm:col-span-2">
|
||||
<FloatLabel variant="on">
|
||||
<InputNumber
|
||||
v-model="editForm.discount_pct"
|
||||
inputId="edit-pct"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:minFractionDigits="0"
|
||||
:maxFractionDigits="2"
|
||||
suffix="%"
|
||||
fluid
|
||||
/>
|
||||
<label for="edit-pct">Desconto %</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<!-- Desconto R$ -->
|
||||
<div class="col-span-6 sm:col-span-2">
|
||||
<FloatLabel variant="on">
|
||||
<InputNumber
|
||||
v-model="editForm.discount_flat"
|
||||
inputId="edit-flat"
|
||||
mode="currency"
|
||||
currency="BRL"
|
||||
locale="pt-BR"
|
||||
:min="0"
|
||||
fluid
|
||||
/>
|
||||
<label for="edit-flat">Desconto R$</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<!-- Vigência: de -->
|
||||
<div class="col-span-6 sm:col-span-2">
|
||||
<FloatLabel variant="on">
|
||||
<DatePicker
|
||||
v-model="editForm.active_from"
|
||||
inputId="edit-from"
|
||||
dateFormat="dd/mm/yy"
|
||||
showButtonBar
|
||||
fluid
|
||||
/>
|
||||
<label for="edit-from">Vigência: de</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<!-- Vigência: até -->
|
||||
<div class="col-span-6 sm:col-span-2">
|
||||
<FloatLabel variant="on">
|
||||
<DatePicker
|
||||
v-model="editForm.active_to"
|
||||
inputId="edit-to"
|
||||
dateFormat="dd/mm/yy"
|
||||
showButtonBar
|
||||
fluid
|
||||
/>
|
||||
<label for="edit-to">Vigência: até</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<!-- Motivo -->
|
||||
<div class="col-span-12">
|
||||
<FloatLabel variant="on">
|
||||
<InputText
|
||||
v-model="editForm.reason"
|
||||
inputId="edit-reason"
|
||||
class="w-full"
|
||||
/>
|
||||
<label for="edit-reason">Motivo (opcional)</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="flex gap-2 mt-1">
|
||||
<Button
|
||||
icon="pi pi-check"
|
||||
size="small"
|
||||
:loading="savingEdit"
|
||||
@click="saveEdit"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="cancelEdit"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modo leitura -->
|
||||
<div v-else class="discount-row">
|
||||
<div class="discount-info">
|
||||
<div class="font-medium text-900">{{ patientName(disc.patient_id) }}</div>
|
||||
<div class="flex flex-wrap gap-2 mt-1">
|
||||
<span v-if="fmtPct(disc.discount_pct)" class="discount-badge">
|
||||
{{ fmtPct(disc.discount_pct) }}
|
||||
</span>
|
||||
<span v-if="fmtBRL(disc.discount_flat)" class="discount-badge">
|
||||
{{ fmtBRL(disc.discount_flat) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-600 mt-0.5">
|
||||
<span v-if="disc.active_from || disc.active_to">
|
||||
{{ fmtDate(disc.active_from) || 'Indefinido' }} →
|
||||
{{ fmtDate(disc.active_to) || 'Indefinido' }}
|
||||
</span>
|
||||
<span v-else>Vigência indefinida</span>
|
||||
</div>
|
||||
<div v-if="disc.reason" class="text-sm text-500 mt-0.5 italic">
|
||||
{{ disc.reason }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="discount-meta">
|
||||
<Tag
|
||||
:value="disc.active ? 'Ativo' : 'Inativo'"
|
||||
:severity="disc.active ? 'success' : 'secondary'"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2 ml-auto">
|
||||
<Button
|
||||
icon="pi pi-pencil"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
text
|
||||
@click="startEdit(disc); addingNew = false"
|
||||
/>
|
||||
<Button
|
||||
v-if="disc.active"
|
||||
icon="pi pi-ban"
|
||||
size="small"
|
||||
severity="danger"
|
||||
text
|
||||
v-tooltip.top="'Desativar'"
|
||||
@click="confirmRemove(disc.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<!-- Divisor antes do form novo -->
|
||||
<Divider v-if="discounts.length && addingNew" />
|
||||
|
||||
<!-- Formulário novo desconto inline -->
|
||||
<div v-if="addingNew" class="discount-row new-row">
|
||||
<div class="grid grid-cols-12 gap-3 flex-1">
|
||||
|
||||
<!-- Paciente -->
|
||||
<div class="col-span-12 sm:col-span-4">
|
||||
<FloatLabel variant="on">
|
||||
<Select
|
||||
v-model="newForm.patient_id"
|
||||
inputId="new-patient"
|
||||
:options="patients"
|
||||
optionLabel="nome_completo"
|
||||
optionValue="id"
|
||||
filter
|
||||
class="w-full"
|
||||
/>
|
||||
<label for="new-patient">Paciente *</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<!-- Desconto % -->
|
||||
<div class="col-span-6 sm:col-span-2">
|
||||
<FloatLabel variant="on">
|
||||
<InputNumber
|
||||
v-model="newForm.discount_pct"
|
||||
inputId="new-pct"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:minFractionDigits="0"
|
||||
:maxFractionDigits="2"
|
||||
suffix="%"
|
||||
fluid
|
||||
/>
|
||||
<label for="new-pct">Desconto %</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<!-- Desconto R$ -->
|
||||
<div class="col-span-6 sm:col-span-2">
|
||||
<FloatLabel variant="on">
|
||||
<InputNumber
|
||||
v-model="newForm.discount_flat"
|
||||
inputId="new-flat"
|
||||
mode="currency"
|
||||
currency="BRL"
|
||||
locale="pt-BR"
|
||||
:min="0"
|
||||
fluid
|
||||
/>
|
||||
<label for="new-flat">Desconto R$</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<!-- Vigência: de -->
|
||||
<div class="col-span-6 sm:col-span-2">
|
||||
<FloatLabel variant="on">
|
||||
<DatePicker
|
||||
v-model="newForm.active_from"
|
||||
inputId="new-from"
|
||||
dateFormat="dd/mm/yy"
|
||||
showButtonBar
|
||||
fluid
|
||||
/>
|
||||
<label for="new-from">Vigência: de</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<!-- Vigência: até -->
|
||||
<div class="col-span-6 sm:col-span-2">
|
||||
<FloatLabel variant="on">
|
||||
<DatePicker
|
||||
v-model="newForm.active_to"
|
||||
inputId="new-to"
|
||||
dateFormat="dd/mm/yy"
|
||||
showButtonBar
|
||||
fluid
|
||||
/>
|
||||
<label for="new-to">Vigência: até</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<!-- Motivo -->
|
||||
<div class="col-span-12">
|
||||
<FloatLabel variant="on">
|
||||
<InputText
|
||||
v-model="newForm.reason"
|
||||
inputId="new-reason"
|
||||
class="w-full"
|
||||
/>
|
||||
<label for="new-reason">Motivo (opcional)</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="flex gap-2 mt-1">
|
||||
<Button
|
||||
icon="pi pi-check"
|
||||
label="Adicionar"
|
||||
size="small"
|
||||
:loading="savingNew"
|
||||
@click="saveNew"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="addingNew = false; newForm = emptyForm()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Estado vazio -->
|
||||
<Card v-else>
|
||||
<template #content>
|
||||
<div class="flex flex-col items-center gap-3 py-6 text-center">
|
||||
<i class="pi pi-percentage text-4xl text-400" />
|
||||
<div class="text-600">Nenhum desconto cadastrado ainda.</div>
|
||||
<Button
|
||||
label="Adicionar primeiro desconto"
|
||||
icon="pi pi-plus"
|
||||
outlined
|
||||
@click="addingNew = true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Dica -->
|
||||
<Message severity="info" :closable="false">
|
||||
<span class="text-sm">
|
||||
Descontos ativos são aplicados automaticamente ao adicionar serviços em sessões do paciente correspondente.
|
||||
Você ainda pode ajustá-los manualmente no diálogo de cada evento.
|
||||
</span>
|
||||
</Message>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cfg-icon-box {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 0.875rem;
|
||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
|
||||
color: var(--p-primary-500, #6366f1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.discount-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-ground);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.discount-row.editing {
|
||||
border-color: var(--p-primary-300, #a5b4fc);
|
||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 4%, var(--surface-ground));
|
||||
}
|
||||
|
||||
.discount-row.new-row {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.discount-info {
|
||||
flex: 1;
|
||||
min-width: 8rem;
|
||||
}
|
||||
|
||||
.discount-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.discount-badge {
|
||||
font-weight: 600;
|
||||
color: var(--p-primary-600, #4f46e5);
|
||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 10%, transparent);
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.875rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,366 @@
|
||||
<!-- src/layout/configuracoes/ConfiguracoesExcecoesFinanceirasPage.vue -->
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useFinancialExceptions } from '@/features/agenda/composables/useFinancialExceptions'
|
||||
|
||||
const toast = useToast()
|
||||
const tenantStore = useTenantStore()
|
||||
|
||||
const { exceptions, loading, error: exceptionsError, load, save } = useFinancialExceptions()
|
||||
|
||||
const ownerId = ref(null)
|
||||
const tenantId = ref(null)
|
||||
const pageLoading = ref(true)
|
||||
|
||||
// ── Tipos de exceção fixos ────────────────────────────────────────────
|
||||
const exceptionTypes = [
|
||||
{ value: 'patient_no_show', label: 'Paciente não compareceu' },
|
||||
{ value: 'patient_cancellation', label: 'Cancelamento pelo paciente' },
|
||||
{ value: 'professional_cancellation', label: 'Cancelamento pelo profissional' },
|
||||
]
|
||||
|
||||
// ── Opções de modo de cobrança ────────────────────────────────────────
|
||||
const chargeModeOptions = [
|
||||
{ value: 'none', label: 'Não cobrar' },
|
||||
{ value: 'full', label: 'Sessão completa' },
|
||||
{ value: 'fixed_fee', label: 'Taxa fixa' },
|
||||
{ value: 'percentage', label: 'Percentual da sessão' },
|
||||
]
|
||||
|
||||
// ── Severidade do badge por charge_mode ──────────────────────────────
|
||||
const chargeModeSeverity = {
|
||||
none: 'secondary',
|
||||
full: 'danger',
|
||||
fixed_fee: 'warn',
|
||||
percentage: 'info',
|
||||
}
|
||||
|
||||
// ── Lookup: para cada exception_type, o registro ativo (owner > clínica) ──
|
||||
// Prioridade: registro próprio do owner > registro global (owner_id IS NULL)
|
||||
function recordFor (type) {
|
||||
const own = exceptions.value.find(e => e.exception_type === type && e.owner_id !== null)
|
||||
const global = exceptions.value.find(e => e.exception_type === type && e.owner_id === null)
|
||||
return own ?? global ?? null
|
||||
}
|
||||
|
||||
function isGlobalRecord (rec) {
|
||||
return rec?.owner_id === null
|
||||
}
|
||||
|
||||
// ── Texto descritivo do charge_mode ──────────────────────────────────
|
||||
function chargeModeLabel (mode) {
|
||||
return chargeModeOptions.find(o => o.value === mode)?.label ?? mode ?? '—'
|
||||
}
|
||||
|
||||
function fmtBRL (v) {
|
||||
if (v == null || v === '') return '—'
|
||||
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
|
||||
}
|
||||
|
||||
function summaryFor (rec) {
|
||||
if (!rec) return 'Não configurado (padrão: não cobrar)'
|
||||
switch (rec.charge_mode) {
|
||||
case 'none': return 'Não cobrar'
|
||||
case 'full': return 'Cobrar sessão completa'
|
||||
case 'fixed_fee': return `Taxa fixa: ${fmtBRL(rec.charge_value)}`
|
||||
case 'percentage': return `${rec.charge_pct ?? 0}% da sessão`
|
||||
default: return '—'
|
||||
}
|
||||
}
|
||||
|
||||
// ── Edição inline ─────────────────────────────────────────────────────
|
||||
const editingType = ref(null)
|
||||
const editForm = ref({})
|
||||
const savingEdit = ref(false)
|
||||
|
||||
function startEdit (type) {
|
||||
const rec = recordFor(type)
|
||||
editingType.value = type
|
||||
editForm.value = {
|
||||
id: rec?.id ?? null,
|
||||
exception_type: type,
|
||||
charge_mode: rec?.charge_mode ?? 'none',
|
||||
charge_value: rec?.charge_value != null ? Number(rec.charge_value) : null,
|
||||
charge_pct: rec?.charge_pct != null ? Number(rec.charge_pct) : null,
|
||||
min_hours_notice: rec?.min_hours_notice != null ? Number(rec.min_hours_notice) : null,
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEdit () {
|
||||
editingType.value = null
|
||||
editForm.value = {}
|
||||
}
|
||||
|
||||
async function saveEdit () {
|
||||
savingEdit.value = true
|
||||
try {
|
||||
await save({
|
||||
id: editForm.value.id,
|
||||
owner_id: ownerId.value,
|
||||
tenant_id: tenantId.value,
|
||||
exception_type: editForm.value.exception_type,
|
||||
charge_mode: editForm.value.charge_mode,
|
||||
charge_value: editForm.value.charge_mode === 'fixed_fee' ? (editForm.value.charge_value ?? null) : null,
|
||||
charge_pct: editForm.value.charge_mode === 'percentage' ? (editForm.value.charge_pct ?? null) : null,
|
||||
min_hours_notice: editForm.value.exception_type === 'patient_cancellation'
|
||||
? (editForm.value.min_hours_notice ?? null)
|
||||
: null,
|
||||
})
|
||||
await load(ownerId.value)
|
||||
cancelEdit()
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Configuração atualizada.', life: 3000 })
|
||||
} catch {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: exceptionsError.value || 'Falha ao salvar.', life: 4000 })
|
||||
} finally {
|
||||
savingEdit.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Computed auxiliares usados no template ────────────────────────────
|
||||
const showChargeValue = computed(() => editForm.value.charge_mode === 'fixed_fee')
|
||||
const showChargePct = computed(() => editForm.value.charge_mode === 'percentage')
|
||||
const showMinHours = computed(() => editForm.value.exception_type === 'patient_cancellation')
|
||||
|
||||
// ── Mount ─────────────────────────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const uid = tenantStore.user?.id || (await supabase.auth.getUser()).data?.user?.id
|
||||
if (!uid) return
|
||||
|
||||
ownerId.value = uid
|
||||
tenantId.value = tenantStore.activeTenantId || null
|
||||
|
||||
await load(uid)
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar.', life: 4000 })
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Toast />
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<!-- Header -->
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="cfg-icon-box">
|
||||
<i class="pi pi-exclamation-triangle text-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-900 font-semibold text-lg">Exceções Financeiras</div>
|
||||
<div class="text-600 text-sm">
|
||||
Defina o que cobrar em situações excepcionais de cancelamento ou falta.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="pageLoading || loading" class="flex justify-center py-10">
|
||||
<ProgressSpinner style="width:40px;height:40px" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
|
||||
<!-- Um card por tipo de exceção -->
|
||||
<Card v-for="et in exceptionTypes" :key="et.value">
|
||||
<template #content>
|
||||
|
||||
<!-- Modo leitura -->
|
||||
<template v-if="editingType !== et.value">
|
||||
<div class="exception-row">
|
||||
<div class="exception-info">
|
||||
<div class="font-semibold text-900 text-base">{{ et.label }}</div>
|
||||
|
||||
<template v-if="recordFor(et.value)">
|
||||
<div class="text-sm text-600 mt-1">
|
||||
{{ summaryFor(recordFor(et.value)) }}
|
||||
<span
|
||||
v-if="et.value === 'patient_cancellation' && recordFor(et.value)?.min_hours_notice"
|
||||
class="text-500"
|
||||
>
|
||||
— cobrar apenas se cancelado com menos de
|
||||
{{ recordFor(et.value).min_hours_notice }}h de antecedência
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-2 flex-wrap">
|
||||
<Tag
|
||||
:value="chargeModeLabel(recordFor(et.value)?.charge_mode)"
|
||||
:severity="chargeModeSeverity[recordFor(et.value)?.charge_mode] ?? 'secondary'"
|
||||
/>
|
||||
<Tag
|
||||
v-if="isGlobalRecord(recordFor(et.value))"
|
||||
value="Regra da clínica"
|
||||
severity="secondary"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="text-sm text-500 mt-1 italic">
|
||||
Não configurado — comportamento padrão: não cobrar.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
v-if="!isGlobalRecord(recordFor(et.value))"
|
||||
label="Configurar"
|
||||
icon="pi pi-cog"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="ml-auto flex-shrink-0"
|
||||
@click="startEdit(et.value)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Modo edição inline -->
|
||||
<template v-else>
|
||||
<div class="exception-row editing">
|
||||
<div class="flex flex-col gap-4 flex-1">
|
||||
|
||||
<div class="font-semibold text-900">{{ et.label }}</div>
|
||||
|
||||
<!-- Modo de cobrança -->
|
||||
<div>
|
||||
<label class="text-sm text-600 block mb-2">Modo de cobrança</label>
|
||||
<SelectButton
|
||||
v-model="editForm.charge_mode"
|
||||
:options="chargeModeOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
class="flex-wrap"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-12 gap-3">
|
||||
|
||||
<!-- Taxa fixa (R$) -->
|
||||
<div v-if="showChargeValue" class="col-span-12 sm:col-span-4">
|
||||
<FloatLabel variant="on">
|
||||
<InputNumber
|
||||
v-model="editForm.charge_value"
|
||||
inputId="edit-charge-value"
|
||||
mode="currency"
|
||||
currency="BRL"
|
||||
locale="pt-BR"
|
||||
:min="0"
|
||||
fluid
|
||||
/>
|
||||
<label for="edit-charge-value">Taxa fixa (R$)</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<!-- Percentual (%) -->
|
||||
<div v-if="showChargePct" class="col-span-12 sm:col-span-4">
|
||||
<FloatLabel variant="on">
|
||||
<InputNumber
|
||||
v-model="editForm.charge_pct"
|
||||
inputId="edit-charge-pct"
|
||||
:min="0"
|
||||
:max="100"
|
||||
:minFractionDigits="0"
|
||||
:maxFractionDigits="2"
|
||||
suffix="%"
|
||||
fluid
|
||||
/>
|
||||
<label for="edit-charge-pct">Percentual da sessão (%)</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
|
||||
<!-- Antecedência mínima (apenas patient_cancellation) -->
|
||||
<div v-if="showMinHours" class="col-span-12 sm:col-span-5">
|
||||
<FloatLabel variant="on">
|
||||
<InputNumber
|
||||
v-model="editForm.min_hours_notice"
|
||||
inputId="edit-min-hours"
|
||||
:min="0"
|
||||
:max="720"
|
||||
suffix=" h"
|
||||
fluid
|
||||
showButtons
|
||||
/>
|
||||
<label for="edit-min-hours">Cobrar se cancelado com menos de X horas (vazio = sempre)</label>
|
||||
</FloatLabel>
|
||||
<small class="text-500 mt-1 block">
|
||||
Deixe em branco para cobrar independentemente da antecedência.
|
||||
</small>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<Button
|
||||
icon="pi pi-check"
|
||||
label="Salvar"
|
||||
size="small"
|
||||
:loading="savingEdit"
|
||||
@click="saveEdit"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
outlined
|
||||
@click="cancelEdit"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Dica -->
|
||||
<Message severity="info" :closable="false">
|
||||
<span class="text-sm">
|
||||
Estas configurações definem o comportamento padrão de cobrança. Você pode
|
||||
ajustá-las individualmente em cada evento na agenda.
|
||||
</span>
|
||||
</Message>
|
||||
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cfg-icon-box {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 0.875rem;
|
||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 12%, transparent);
|
||||
color: var(--p-primary-500, #6366f1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.exception-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.exception-row.editing {
|
||||
border: 1px solid var(--p-primary-300, #a5b4fc);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem;
|
||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 4%, var(--surface-ground));
|
||||
}
|
||||
|
||||
.exception-info {
|
||||
flex: 1;
|
||||
min-width: 10rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,70 +1,116 @@
|
||||
<!-- src/layout/configuracoes/ConfiguracoesPrecificacaoPage.vue -->
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
import { useTenantStore } from '@/stores/tenantStore'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import { useServices } from '@/features/agenda/composables/useServices'
|
||||
|
||||
const toast = useToast()
|
||||
const tenantStore = useTenantStore()
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
|
||||
const ownerId = ref(null)
|
||||
const tenantId = ref(null)
|
||||
const { services, loading, error: servicesError, load, save, remove } = useServices()
|
||||
|
||||
// ── Tipos de compromisso do tenant ─────────────────────────────────
|
||||
const commitments = ref([]) // [{ id, label, native_key }]
|
||||
const ownerId = ref(null)
|
||||
const tenantId = ref(null)
|
||||
const slotMode = ref('fixed')
|
||||
const pageLoading = ref(true)
|
||||
|
||||
// ── Preços: Map<commitmentId | '__default__', { price, notes }> ────
|
||||
// '__default__' = linha com determined_commitment_id IS NULL
|
||||
const prices = ref({}) // { [key]: { price: number|null, notes: '' } }
|
||||
const isDynamic = computed(() => slotMode.value === 'dynamic')
|
||||
|
||||
// ── Carregar commitments do tenant ─────────────────────────────────
|
||||
async function loadCommitments () {
|
||||
if (!tenantId.value) return
|
||||
const { data, error } = await supabase
|
||||
.from('determined_commitments')
|
||||
.select('id, name, native_key, active')
|
||||
.eq('tenant_id', tenantId.value)
|
||||
.eq('active', true)
|
||||
.order('name')
|
||||
// ── Formulário novo serviço ──────────────────────────────────────────
|
||||
const emptyForm = () => ({ name: '', description: '', price: null, duration_min: null })
|
||||
|
||||
if (error) throw error
|
||||
const newForm = ref(emptyForm())
|
||||
const addingNew = ref(false)
|
||||
const savingNew = ref(false)
|
||||
|
||||
commitments.value = data || []
|
||||
}
|
||||
// ── Edição inline ────────────────────────────────────────────────────
|
||||
const editingId = ref(null)
|
||||
const editForm = ref({})
|
||||
const savingEdit = ref(false)
|
||||
|
||||
// ── Carregar preços existentes ──────────────────────────────────────
|
||||
async function loadPrices (uid) {
|
||||
const { data, error } = await supabase
|
||||
.from('professional_pricing')
|
||||
.select('id, determined_commitment_id, price, notes')
|
||||
.eq('owner_id', uid)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
const map = {}
|
||||
for (const row of (data || [])) {
|
||||
const key = row.determined_commitment_id ?? '__default__'
|
||||
map[key] = { price: row.price != null ? Number(row.price) : null, notes: row.notes ?? '' }
|
||||
}
|
||||
prices.value = map
|
||||
}
|
||||
|
||||
// ── Garantir que todos os commitments + default têm entrada no mapa
|
||||
function ensureDefaults () {
|
||||
if (!prices.value['__default__']) {
|
||||
prices.value['__default__'] = { price: null, notes: '' }
|
||||
}
|
||||
for (const c of commitments.value) {
|
||||
if (!prices.value[c.id]) {
|
||||
prices.value[c.id] = { price: null, notes: '' }
|
||||
}
|
||||
function startEdit (svc) {
|
||||
editingId.value = svc.id
|
||||
editForm.value = {
|
||||
id: svc.id,
|
||||
owner_id: ownerId.value,
|
||||
tenant_id: tenantId.value,
|
||||
name: svc.name,
|
||||
description: svc.description ?? '',
|
||||
price: svc.price != null ? Number(svc.price) : null,
|
||||
duration_min: svc.duration_min ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Mount ───────────────────────────────────────────────────────────
|
||||
function cancelEdit () {
|
||||
editingId.value = null
|
||||
editForm.value = {}
|
||||
}
|
||||
|
||||
async function saveEdit () {
|
||||
if (!editForm.value.name?.trim() || editForm.value.price == null) {
|
||||
toast.add({ severity: 'warn', summary: 'Campos obrigatórios', detail: 'Nome e preço são obrigatórios.', life: 3000 })
|
||||
return
|
||||
}
|
||||
savingEdit.value = true
|
||||
try {
|
||||
await save({
|
||||
...editForm.value,
|
||||
name: editForm.value.name.trim(),
|
||||
description: editForm.value.description?.trim() || null,
|
||||
duration_min: isDynamic.value ? (editForm.value.duration_min ?? null) : null,
|
||||
})
|
||||
await load(ownerId.value)
|
||||
cancelEdit()
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Serviço atualizado.', life: 3000 })
|
||||
} catch {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: servicesError.value || 'Falha ao salvar.', life: 4000 })
|
||||
} finally {
|
||||
savingEdit.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveNew () {
|
||||
if (!newForm.value.name?.trim() || newForm.value.price == null) {
|
||||
toast.add({ severity: 'warn', summary: 'Campos obrigatórios', detail: 'Nome e preço são obrigatórios.', life: 3000 })
|
||||
return
|
||||
}
|
||||
savingNew.value = true
|
||||
try {
|
||||
await save({
|
||||
owner_id: ownerId.value,
|
||||
tenant_id: tenantId.value,
|
||||
name: newForm.value.name.trim(),
|
||||
description: newForm.value.description?.trim() || null,
|
||||
price: newForm.value.price,
|
||||
duration_min: isDynamic.value ? (newForm.value.duration_min ?? null) : null,
|
||||
})
|
||||
await load(ownerId.value)
|
||||
newForm.value = emptyForm()
|
||||
addingNew.value = false
|
||||
toast.add({ severity: 'success', summary: 'Adicionado', detail: 'Serviço criado com sucesso.', life: 3000 })
|
||||
} catch {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: servicesError.value || 'Falha ao criar.', life: 4000 })
|
||||
} finally {
|
||||
savingNew.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRemove (id) {
|
||||
try {
|
||||
await remove(id)
|
||||
toast.add({ severity: 'success', summary: 'Removido', detail: 'Serviço removido.', life: 3000 })
|
||||
} catch {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: servicesError.value || 'Falha ao remover.', life: 4000 })
|
||||
}
|
||||
}
|
||||
|
||||
function fmtBRL (v) {
|
||||
if (v == null || v === '') return '—'
|
||||
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const uid = tenantStore.user?.id || (await supabase.auth.getUser()).data?.user?.id
|
||||
@@ -73,78 +119,21 @@ onMounted(async () => {
|
||||
ownerId.value = uid
|
||||
tenantId.value = tenantStore.activeTenantId || null
|
||||
|
||||
await Promise.all([
|
||||
loadCommitments(),
|
||||
loadPrices(uid),
|
||||
])
|
||||
const { data: cfg } = await supabase
|
||||
.from('agenda_configuracoes')
|
||||
.select('slot_mode')
|
||||
.eq('owner_id', uid)
|
||||
.maybeSingle()
|
||||
|
||||
ensureDefaults()
|
||||
slotMode.value = cfg?.slot_mode ?? 'fixed'
|
||||
|
||||
await load(uid)
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar.', life: 4000 })
|
||||
} finally {
|
||||
loading.value = false
|
||||
pageLoading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// ── Salvar todos os preços configurados ────────────────────────────
|
||||
async function save () {
|
||||
if (!ownerId.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
const uid = ownerId.value
|
||||
const tid = tenantId.value
|
||||
|
||||
const rows = []
|
||||
|
||||
// Linha padrão (NULL commitment)
|
||||
const def = prices.value['__default__']
|
||||
if (def?.price != null && def.price !== '') {
|
||||
rows.push({
|
||||
owner_id: uid,
|
||||
tenant_id: tid,
|
||||
determined_commitment_id: null,
|
||||
price: Number(def.price),
|
||||
notes: def.notes?.trim() || null,
|
||||
})
|
||||
}
|
||||
|
||||
// Linhas por tipo de compromisso
|
||||
for (const c of commitments.value) {
|
||||
const entry = prices.value[c.id]
|
||||
if (entry?.price != null && entry.price !== '') {
|
||||
rows.push({
|
||||
owner_id: uid,
|
||||
tenant_id: tid,
|
||||
determined_commitment_id: c.id,
|
||||
price: Number(entry.price),
|
||||
notes: entry.notes?.trim() || null,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!rows.length) {
|
||||
toast.add({ severity: 'warn', summary: 'Aviso', detail: 'Nenhum preço configurado para salvar.', life: 3000 })
|
||||
return
|
||||
}
|
||||
|
||||
const { error } = await supabase
|
||||
.from('professional_pricing')
|
||||
.upsert(rows, { onConflict: 'owner_id,determined_commitment_id' })
|
||||
|
||||
if (error) throw error
|
||||
|
||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Precificação atualizada!', life: 3000 })
|
||||
} catch (e) {
|
||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao salvar.', life: 4000 })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function fmtBRL (v) {
|
||||
if (v == null || v === '') return '—'
|
||||
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -152,7 +141,7 @@ function fmtBRL (v) {
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
|
||||
<!-- Header card -->
|
||||
<!-- Header -->
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||
@@ -161,140 +150,174 @@ function fmtBRL (v) {
|
||||
<i class="pi pi-tag text-lg" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-900 font-semibold text-lg">Precificação</div>
|
||||
<div class="text-600 text-sm">Defina o valor padrão da sessão e valores específicos por tipo de compromisso.</div>
|
||||
<div class="text-900 font-semibold text-lg">Serviços e Precificação</div>
|
||||
<div class="text-600 text-sm">
|
||||
Gerencie os serviços que você oferece e seus respectivos preços.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
label="Salvar preços"
|
||||
icon="pi pi-check"
|
||||
:loading="saving"
|
||||
:disabled="loading"
|
||||
@click="save"
|
||||
label="Novo serviço"
|
||||
icon="pi pi-plus"
|
||||
:disabled="pageLoading || addingNew"
|
||||
@click="addingNew = true; cancelEdit()"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="flex justify-center py-10">
|
||||
<div v-if="pageLoading || loading" class="flex justify-center py-10">
|
||||
<ProgressSpinner style="width:40px;height:40px" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
|
||||
<!-- Preço padrão -->
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-star text-primary-500" />
|
||||
<span class="font-semibold text-900">Preço padrão (fallback)</span>
|
||||
</div>
|
||||
<div class="text-600 text-sm">
|
||||
Aplicado quando o tipo de compromisso da sessão não tem um preço específico cadastrado.
|
||||
</div>
|
||||
<Message v-if="isDynamic" severity="info" :closable="false">
|
||||
<span class="text-sm">
|
||||
Modo <b>dinâmico</b> ativo — a duração da sessão é definida pelo serviço selecionado.
|
||||
</span>
|
||||
</Message>
|
||||
|
||||
<div class="grid grid-cols-12 gap-3 mt-1">
|
||||
<div class="col-span-12 sm:col-span-5">
|
||||
<!-- Formulário novo serviço -->
|
||||
<Card v-if="addingNew">
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-plus-circle text-primary-500" />
|
||||
<span>Novo serviço</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="grid grid-cols-12 gap-3">
|
||||
<div class="col-span-12 sm:col-span-4">
|
||||
<FloatLabel variant="on">
|
||||
<InputText v-model="newForm.name" inputId="new-name" class="w-full" />
|
||||
<label for="new-name">Nome *</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div class="col-span-12 sm:col-span-3">
|
||||
<FloatLabel variant="on">
|
||||
<InputNumber
|
||||
v-model="newForm.price"
|
||||
inputId="new-price"
|
||||
mode="currency"
|
||||
currency="BRL"
|
||||
locale="pt-BR"
|
||||
:min="0"
|
||||
:minFractionDigits="2"
|
||||
fluid
|
||||
/>
|
||||
<label for="new-price">Preço (R$) *</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div v-if="isDynamic" class="col-span-12 sm:col-span-2">
|
||||
<FloatLabel variant="on">
|
||||
<InputNumber v-model="newForm.duration_min" inputId="new-duration" :min="1" :max="480" fluid />
|
||||
<label for="new-duration">Duração (min)</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div class="col-span-12 sm:col-span-3">
|
||||
<FloatLabel variant="on">
|
||||
<InputText v-model="newForm.description" inputId="new-desc" class="w-full" />
|
||||
<label for="new-desc">Descrição (opcional)</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 justify-end mt-4">
|
||||
<Button label="Cancelar" severity="secondary" outlined @click="addingNew = false; newForm = emptyForm()" />
|
||||
<Button label="Salvar" icon="pi pi-check" :loading="savingNew" @click="saveNew" />
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Lista vazia -->
|
||||
<Card v-if="!services.length && !addingNew">
|
||||
<template #content>
|
||||
<div class="text-center py-6 text-color-secondary">
|
||||
<i class="pi pi-tag text-4xl opacity-30 mb-3 block" />
|
||||
<div class="font-medium mb-1">Nenhum serviço cadastrado</div>
|
||||
<div class="text-sm">Clique em "Novo serviço" para começar.</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Lista de serviços -->
|
||||
<Card v-for="svc in services" :key="svc.id">
|
||||
<template #content>
|
||||
|
||||
<!-- Modo edição -->
|
||||
<template v-if="editingId === svc.id">
|
||||
<div class="grid grid-cols-12 gap-3">
|
||||
<div class="col-span-12 sm:col-span-4">
|
||||
<FloatLabel variant="on">
|
||||
<InputText v-model="editForm.name" :inputId="`edit-name-${svc.id}`" class="w-full" />
|
||||
<label :for="`edit-name-${svc.id}`">Nome *</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div class="col-span-12 sm:col-span-3">
|
||||
<FloatLabel variant="on">
|
||||
<InputNumber
|
||||
v-model="prices['__default__'].price"
|
||||
inputId="price-default"
|
||||
v-model="editForm.price"
|
||||
:inputId="`edit-price-${svc.id}`"
|
||||
mode="currency"
|
||||
currency="BRL"
|
||||
locale="pt-BR"
|
||||
:min="0"
|
||||
:max="99999"
|
||||
:minFractionDigits="2"
|
||||
fluid
|
||||
placeholder="R$ 0,00"
|
||||
/>
|
||||
<label for="price-default">Valor da sessão (R$)</label>
|
||||
<label :for="`edit-price-${svc.id}`">Preço (R$) *</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div class="col-span-12 sm:col-span-7">
|
||||
<div v-if="isDynamic" class="col-span-12 sm:col-span-2">
|
||||
<FloatLabel variant="on">
|
||||
<InputText
|
||||
v-model="prices['__default__'].notes"
|
||||
inputId="notes-default"
|
||||
class="w-full"
|
||||
placeholder="Ex: Particular, valor padrão"
|
||||
/>
|
||||
<label for="notes-default">Observação (opcional)</label>
|
||||
<InputNumber v-model="editForm.duration_min" :inputId="`edit-dur-${svc.id}`" :min="1" :max="480" fluid />
|
||||
<label :for="`edit-dur-${svc.id}`">Duração (min)</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div class="col-span-12 sm:col-span-3">
|
||||
<FloatLabel variant="on">
|
||||
<InputText v-model="editForm.description" :inputId="`edit-desc-${svc.id}`" class="w-full" />
|
||||
<label :for="`edit-desc-${svc.id}`">Descrição (opcional)</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
<div class="flex gap-2 justify-end mt-4">
|
||||
<Button label="Cancelar" severity="secondary" outlined @click="cancelEdit" />
|
||||
<Button label="Salvar" icon="pi pi-check" :loading="savingEdit" @click="saveEdit" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Preços por tipo de compromisso -->
|
||||
<Card v-if="commitments.length">
|
||||
<template #title>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-list" />
|
||||
<span>Por tipo de compromisso</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div class="text-600 text-sm mb-4">
|
||||
Valores específicos sobrepõem o preço padrão quando o tipo de compromisso coincide.
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div
|
||||
v-for="c in commitments"
|
||||
:key="c.id"
|
||||
class="commitment-row"
|
||||
>
|
||||
<div class="commitment-label">
|
||||
<div class="font-medium text-900">{{ c.name }}</div>
|
||||
<div v-if="prices[c.id]?.price != null" class="text-xs text-color-secondary mt-0.5">
|
||||
{{ fmtBRL(prices[c.id]?.price) }}
|
||||
<!-- Modo leitura -->
|
||||
<template v-else>
|
||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="cfg-icon-box-sm">
|
||||
<i class="pi pi-tag" />
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold text-900">{{ svc.name }}</div>
|
||||
<div class="text-sm text-color-secondary flex flex-wrap gap-x-3 gap-y-0.5 mt-0.5">
|
||||
<span><b class="text-primary-500">{{ fmtBRL(svc.price) }}</b></span>
|
||||
<span v-if="svc.duration_min">{{ svc.duration_min }}min</span>
|
||||
<span v-if="svc.description" class="italic">{{ svc.description }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-12 gap-3 flex-1">
|
||||
<div class="col-span-12 sm:col-span-5">
|
||||
<FloatLabel variant="on">
|
||||
<InputNumber
|
||||
v-model="prices[c.id].price"
|
||||
:inputId="`price-${c.id}`"
|
||||
mode="currency"
|
||||
currency="BRL"
|
||||
locale="pt-BR"
|
||||
:min="0"
|
||||
:max="99999"
|
||||
:minFractionDigits="2"
|
||||
fluid
|
||||
placeholder="R$ 0,00"
|
||||
/>
|
||||
<label :for="`price-${c.id}`">Valor (R$)</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div class="col-span-12 sm:col-span-7">
|
||||
<FloatLabel variant="on">
|
||||
<InputText
|
||||
v-model="prices[c.id].notes"
|
||||
:id="`notes-${c.id}`"
|
||||
class="w-full"
|
||||
placeholder="Ex: Convênio, valor reduzido..."
|
||||
/>
|
||||
<label :for="`notes-${c.id}`">Observação (opcional)</label>
|
||||
</FloatLabel>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<Tag value="Ativo" severity="success" />
|
||||
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" v-tooltip.top="'Editar'" @click="startEdit(svc)" />
|
||||
<Button icon="pi pi-trash" severity="danger" outlined size="small" v-tooltip.top="'Remover'" @click="confirmRemove(svc.id)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</template>
|
||||
</Card>
|
||||
|
||||
<!-- Dica -->
|
||||
<Message severity="info" :closable="false">
|
||||
<span class="text-sm">
|
||||
O preço configurado aqui é preenchido automaticamente ao criar uma sessão na agenda.
|
||||
Você ainda pode ajustá-lo manualmente no diálogo de cada evento.
|
||||
Os serviços cadastrados aqui aparecem para seleção ao criar ou editar um evento na agenda.
|
||||
</span>
|
||||
</Message>
|
||||
|
||||
@@ -314,19 +337,14 @@ function fmtBRL (v) {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.commitment-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid var(--surface-border);
|
||||
background: var(--surface-ground);
|
||||
}
|
||||
|
||||
.commitment-label {
|
||||
min-width: 9rem;
|
||||
.cfg-icon-box-sm {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 0.625rem;
|
||||
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 10%, transparent);
|
||||
color: var(--p-primary-500, #6366f1);
|
||||
flex-shrink: 0;
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -41,6 +41,21 @@ const configuracoesRoutes = {
|
||||
path: 'precificacao',
|
||||
name: 'ConfiguracoesPrecificacao',
|
||||
component: () => import('@/layout/configuracoes/ConfiguracoesPrecificacaoPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'descontos',
|
||||
name: 'ConfiguracoesDescontos',
|
||||
component: () => import('@/layout/configuracoes/ConfiguracoesDescontosPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'excecoes-financeiras',
|
||||
name: 'ConfiguracoesExcecoesFinanceiras',
|
||||
component: () => import('@/layout/configuracoes/ConfiguracoesExcecoesFinanceirasPage.vue')
|
||||
},
|
||||
{
|
||||
path: 'convenios',
|
||||
name: 'ConfiguracoesConvenios',
|
||||
component: () => import('@/layout/configuracoes/ConfiguracoesConveniosPage.vue')
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
-- ============================================================
|
||||
-- MIGRATION: Serviços, Precificação e Faturamento
|
||||
-- Spec: Setup Wizard + Reestruturação de Serviços e Precificação
|
||||
-- Data: 2026-03-12
|
||||
-- ============================================================
|
||||
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────
|
||||
-- 1. TABELA: services
|
||||
-- Catálogo de serviços de um profissional. Substitui a lógica
|
||||
-- de professional_pricing como fonte de preço/duração.
|
||||
-- ──────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE public.services (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
name text NOT NULL,
|
||||
description text,
|
||||
price numeric(10,2) NOT NULL,
|
||||
duration_min integer, -- NULL se slot_mode = 'fixed'
|
||||
active boolean DEFAULT true NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
updated_at timestamp with time zone DEFAULT now(),
|
||||
|
||||
CONSTRAINT services_pkey PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
ALTER TABLE public.services
|
||||
ADD CONSTRAINT services_owner_id_fkey
|
||||
FOREIGN KEY (owner_id) REFERENCES auth.users(id) ON DELETE CASCADE;
|
||||
|
||||
CREATE INDEX services_owner_idx ON public.services USING btree (owner_id);
|
||||
CREATE INDEX services_tenant_idx ON public.services USING btree (tenant_id);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.set_services_updated_at()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trg_services_updated_at
|
||||
BEFORE UPDATE ON public.services
|
||||
FOR EACH ROW EXECUTE FUNCTION public.set_services_updated_at();
|
||||
|
||||
ALTER TABLE public.services ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "services: owner full access"
|
||||
ON public.services
|
||||
USING (owner_id = auth.uid())
|
||||
WITH CHECK (owner_id = auth.uid());
|
||||
|
||||
GRANT ALL ON TABLE public.services TO postgres;
|
||||
GRANT ALL ON TABLE public.services TO anon;
|
||||
GRANT ALL ON TABLE public.services TO authenticated;
|
||||
GRANT ALL ON TABLE public.services TO service_role;
|
||||
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────
|
||||
-- 2. TABELA: commitment_services
|
||||
-- Itens de serviço vinculados a um evento (agenda_eventos).
|
||||
-- O total do compromisso = SUM(final_price).
|
||||
-- ──────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE public.commitment_services (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
commitment_id uuid NOT NULL, -- FK para agenda_eventos.id
|
||||
service_id uuid NOT NULL, -- FK para services.id
|
||||
quantity integer DEFAULT 1 NOT NULL,
|
||||
unit_price numeric(10,2) NOT NULL, -- snapshot no momento da adição
|
||||
discount_pct numeric(5,2) DEFAULT 0,
|
||||
discount_flat numeric(10,2) DEFAULT 0,
|
||||
final_price numeric(10,2) NOT NULL, -- (unit_price * qty) - descontos
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
|
||||
CONSTRAINT commitment_services_pkey PRIMARY KEY (id),
|
||||
CONSTRAINT commitment_services_quantity_chk CHECK (quantity > 0),
|
||||
CONSTRAINT commitment_services_disc_pct_chk CHECK (discount_pct >= 0 AND discount_pct <= 100),
|
||||
CONSTRAINT commitment_services_disc_flat_chk CHECK (discount_flat >= 0),
|
||||
CONSTRAINT commitment_services_final_price_chk CHECK (final_price >= 0)
|
||||
);
|
||||
|
||||
ALTER TABLE public.commitment_services
|
||||
ADD CONSTRAINT commitment_services_commitment_id_fkey
|
||||
FOREIGN KEY (commitment_id) REFERENCES public.agenda_eventos(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE public.commitment_services
|
||||
ADD CONSTRAINT commitment_services_service_id_fkey
|
||||
FOREIGN KEY (service_id) REFERENCES public.services(id) ON DELETE RESTRICT;
|
||||
|
||||
CREATE INDEX commitment_services_commitment_idx ON public.commitment_services USING btree (commitment_id);
|
||||
CREATE INDEX commitment_services_service_idx ON public.commitment_services USING btree (service_id);
|
||||
|
||||
ALTER TABLE public.commitment_services ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "commitment_services: owner full access"
|
||||
ON public.commitment_services
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.services s
|
||||
WHERE s.id = service_id
|
||||
AND s.owner_id = auth.uid()
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.services s
|
||||
WHERE s.id = service_id
|
||||
AND s.owner_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
GRANT ALL ON TABLE public.commitment_services TO postgres;
|
||||
GRANT ALL ON TABLE public.commitment_services TO anon;
|
||||
GRANT ALL ON TABLE public.commitment_services TO authenticated;
|
||||
GRANT ALL ON TABLE public.commitment_services TO service_role;
|
||||
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────
|
||||
-- 3. TABELA: billing_contracts
|
||||
-- Modelo de cobrança desacoplado do agendamento.
|
||||
-- tipos: 'per_session' | 'package' | 'subscription'
|
||||
-- ──────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE public.billing_contracts (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
patient_id uuid NOT NULL,
|
||||
type text NOT NULL,
|
||||
|
||||
-- Campos usados apenas para type = 'package'
|
||||
total_sessions integer,
|
||||
sessions_used integer DEFAULT 0,
|
||||
package_price numeric(10,2),
|
||||
|
||||
-- Campos usados apenas para type = 'subscription'
|
||||
amount numeric(10,2),
|
||||
billing_interval text, -- 'monthly' | 'weekly'
|
||||
|
||||
active_from timestamp with time zone DEFAULT now(),
|
||||
active_to timestamp with time zone,
|
||||
status text DEFAULT 'active' NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
|
||||
CONSTRAINT billing_contracts_pkey PRIMARY KEY (id),
|
||||
CONSTRAINT billing_contracts_type_chk CHECK (type IN ('per_session', 'package', 'subscription')),
|
||||
CONSTRAINT billing_contracts_status_chk CHECK (status IN ('active', 'completed', 'cancelled')),
|
||||
CONSTRAINT billing_contracts_interval_chk CHECK (billing_interval IS NULL OR billing_interval IN ('monthly', 'weekly')),
|
||||
CONSTRAINT billing_contracts_sess_used_chk CHECK (sessions_used IS NULL OR sessions_used >= 0),
|
||||
CONSTRAINT billing_contracts_total_sess_chk CHECK (total_sessions IS NULL OR total_sessions > 0)
|
||||
);
|
||||
|
||||
ALTER TABLE public.billing_contracts
|
||||
ADD CONSTRAINT billing_contracts_owner_id_fkey
|
||||
FOREIGN KEY (owner_id) REFERENCES auth.users(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE public.billing_contracts
|
||||
ADD CONSTRAINT billing_contracts_patient_id_fkey
|
||||
FOREIGN KEY (patient_id) REFERENCES public.patients(id) ON DELETE CASCADE;
|
||||
|
||||
CREATE INDEX billing_contracts_owner_idx ON public.billing_contracts USING btree (owner_id);
|
||||
CREATE INDEX billing_contracts_tenant_idx ON public.billing_contracts USING btree (tenant_id);
|
||||
CREATE INDEX billing_contracts_patient_idx ON public.billing_contracts USING btree (patient_id);
|
||||
|
||||
ALTER TABLE public.billing_contracts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY "billing_contracts: owner full access"
|
||||
ON public.billing_contracts
|
||||
USING (owner_id = auth.uid())
|
||||
WITH CHECK (owner_id = auth.uid());
|
||||
|
||||
GRANT ALL ON TABLE public.billing_contracts TO postgres;
|
||||
GRANT ALL ON TABLE public.billing_contracts TO anon;
|
||||
GRANT ALL ON TABLE public.billing_contracts TO authenticated;
|
||||
GRANT ALL ON TABLE public.billing_contracts TO service_role;
|
||||
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────
|
||||
-- 4. ALTER: agenda_configuracoes — adicionar slot_mode
|
||||
-- DEFAULT 'fixed' preserva o comportamento atual de todos os
|
||||
-- registros existentes sem necessidade de UPDATE.
|
||||
-- ──────────────────────────────────────────────────────────────
|
||||
|
||||
ALTER TABLE public.agenda_configuracoes
|
||||
ADD COLUMN IF NOT EXISTS slot_mode text DEFAULT 'fixed' NOT NULL;
|
||||
|
||||
ALTER TABLE public.agenda_configuracoes
|
||||
ADD CONSTRAINT agenda_configuracoes_slot_mode_chk
|
||||
CHECK (slot_mode IN ('fixed', 'dynamic'));
|
||||
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────
|
||||
-- 5. ALTER: agenda_eventos — campos de faturamento
|
||||
-- billing_contract_id é nullable — compatível com registros existentes.
|
||||
-- billed tem DEFAULT false — preenchido automaticamente nos existentes.
|
||||
-- ──────────────────────────────────────────────────────────────
|
||||
|
||||
ALTER TABLE public.agenda_eventos
|
||||
ADD COLUMN IF NOT EXISTS billing_contract_id uuid,
|
||||
ADD COLUMN IF NOT EXISTS billed boolean DEFAULT false NOT NULL;
|
||||
|
||||
ALTER TABLE public.agenda_eventos
|
||||
ADD CONSTRAINT agenda_eventos_billing_contract_id_fkey
|
||||
FOREIGN KEY (billing_contract_id) REFERENCES public.billing_contracts(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX agenda_eventos_billing_contract_idx
|
||||
ON public.agenda_eventos USING btree (billing_contract_id)
|
||||
WHERE billing_contract_id IS NOT NULL;
|
||||
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────
|
||||
-- 6. MIGRAÇÃO DE DADOS: professional_pricing → services
|
||||
-- Cria um registro em services para cada linha existente.
|
||||
-- name vem do JOIN com determined_commitments quando o ID não
|
||||
-- for NULL; caso contrário usa fallback genérico 'Atendimento'.
|
||||
-- duration_min fica NULL pois todos os registros existentes
|
||||
-- operam em slot_mode = 'fixed'.
|
||||
-- ──────────────────────────────────────────────────────────────
|
||||
|
||||
INSERT INTO public.services (owner_id, tenant_id, name, price, duration_min, active)
|
||||
SELECT
|
||||
pp.owner_id,
|
||||
pp.tenant_id,
|
||||
COALESCE(dc.name, 'Atendimento') AS name,
|
||||
pp.price,
|
||||
NULL AS duration_min,
|
||||
true AS active
|
||||
FROM public.professional_pricing pp
|
||||
LEFT JOIN public.determined_commitments dc ON dc.id = pp.determined_commitment_id;
|
||||
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────
|
||||
-- 7. DEPRECAR FK em professional_pricing
|
||||
-- Remove apenas a constraint FK para determined_commitments.
|
||||
-- A tabela em si permanece intacta — remoção em release futura.
|
||||
-- ──────────────────────────────────────────────────────────────
|
||||
|
||||
ALTER TABLE public.professional_pricing
|
||||
DROP CONSTRAINT IF EXISTS professional_pricing_determined_commitment_id_fkey;
|
||||
|
||||
COMMENT ON TABLE public.professional_pricing IS
|
||||
'DEPRECATED: substituída por public.services. Manter até próxima release de limpeza.';
|
||||
@@ -0,0 +1,160 @@
|
||||
-- ============================================================
|
||||
-- MIGRATION v2: Desconto Recorrente e Exceções Financeiras
|
||||
-- Spec: spec-v2.docx — Seção 4 (patient_discounts) e 5 (financial_exceptions)
|
||||
-- Data: 2026-03-12
|
||||
-- ============================================================
|
||||
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────
|
||||
-- 1. TABELA: patient_discounts
|
||||
-- Desconto recorrente vinculado a um paciente específico.
|
||||
-- Aplicado automaticamente ao adicionar serviços a um evento.
|
||||
-- ──────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.patient_discounts (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
patient_id uuid NOT NULL,
|
||||
discount_pct numeric(5,2) DEFAULT 0,
|
||||
discount_flat numeric(10,2) DEFAULT 0,
|
||||
reason text,
|
||||
active boolean DEFAULT true NOT NULL,
|
||||
active_from timestamp with time zone DEFAULT now(),
|
||||
active_to timestamp with time zone,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
|
||||
CONSTRAINT patient_discounts_pkey PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'patient_discounts_owner_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE public.patient_discounts
|
||||
ADD CONSTRAINT patient_discounts_owner_id_fkey
|
||||
FOREIGN KEY (owner_id) REFERENCES auth.users(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'patient_discounts_patient_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE public.patient_discounts
|
||||
ADD CONSTRAINT patient_discounts_patient_id_fkey
|
||||
FOREIGN KEY (patient_id) REFERENCES public.patients(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS patient_discounts_owner_idx ON public.patient_discounts USING btree (owner_id);
|
||||
CREATE INDEX IF NOT EXISTS patient_discounts_tenant_idx ON public.patient_discounts USING btree (tenant_id);
|
||||
CREATE INDEX IF NOT EXISTS patient_discounts_patient_idx ON public.patient_discounts USING btree (patient_id);
|
||||
|
||||
ALTER TABLE public.patient_discounts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_policies
|
||||
WHERE schemaname = 'public'
|
||||
AND tablename = 'patient_discounts'
|
||||
AND policyname = 'patient_discounts: owner full access'
|
||||
) THEN
|
||||
CREATE POLICY "patient_discounts: owner full access"
|
||||
ON public.patient_discounts
|
||||
USING (owner_id = auth.uid())
|
||||
WITH CHECK (owner_id = auth.uid());
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
GRANT ALL ON TABLE public.patient_discounts TO postgres;
|
||||
GRANT ALL ON TABLE public.patient_discounts TO anon;
|
||||
GRANT ALL ON TABLE public.patient_discounts TO authenticated;
|
||||
GRANT ALL ON TABLE public.patient_discounts TO service_role;
|
||||
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────
|
||||
-- 2. TABELA: financial_exceptions
|
||||
-- Regras de cobrança para situações excepcionais.
|
||||
-- owner_id NULL = regra da clínica (leitura para membros do tenant).
|
||||
-- ──────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.financial_exceptions (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid, -- NULL = regra da clínica
|
||||
tenant_id uuid NOT NULL,
|
||||
exception_type text NOT NULL,
|
||||
charge_mode text NOT NULL,
|
||||
charge_value numeric(10,2), -- usado quando charge_mode = 'fixed_fee'
|
||||
charge_pct numeric(5,2), -- usado quando charge_mode = 'percentage'
|
||||
min_hours_notice integer, -- usado em patient_cancellation
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
updated_at timestamp with time zone DEFAULT now(),
|
||||
|
||||
CONSTRAINT financial_exceptions_pkey PRIMARY KEY (id),
|
||||
CONSTRAINT financial_exceptions_type_chk CHECK (exception_type IN ('patient_no_show', 'patient_cancellation', 'professional_cancellation')),
|
||||
CONSTRAINT financial_exceptions_charge_chk CHECK (charge_mode IN ('none', 'full', 'fixed_fee', 'percentage'))
|
||||
);
|
||||
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'financial_exceptions_owner_id_fkey'
|
||||
) THEN
|
||||
ALTER TABLE public.financial_exceptions
|
||||
ADD CONSTRAINT financial_exceptions_owner_id_fkey
|
||||
FOREIGN KEY (owner_id) REFERENCES auth.users(id) ON DELETE CASCADE;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS financial_exceptions_owner_idx ON public.financial_exceptions USING btree (owner_id);
|
||||
CREATE INDEX IF NOT EXISTS financial_exceptions_tenant_idx ON public.financial_exceptions USING btree (tenant_id);
|
||||
|
||||
DROP TRIGGER IF EXISTS trg_financial_exceptions_updated_at
|
||||
ON public.financial_exceptions;
|
||||
CREATE TRIGGER trg_financial_exceptions_updated_at
|
||||
BEFORE UPDATE ON public.financial_exceptions
|
||||
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
|
||||
ALTER TABLE public.financial_exceptions ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Política 1: owner full access (profissional edita suas próprias regras)
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_policies
|
||||
WHERE schemaname = 'public'
|
||||
AND tablename = 'financial_exceptions'
|
||||
AND policyname = 'financial_exceptions: owner full access'
|
||||
) THEN
|
||||
CREATE POLICY "financial_exceptions: owner full access"
|
||||
ON public.financial_exceptions
|
||||
USING (owner_id = auth.uid())
|
||||
WITH CHECK (owner_id = auth.uid());
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Política 2: leitura das regras da clínica (owner_id IS NULL) para membros do tenant
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_policies
|
||||
WHERE schemaname = 'public'
|
||||
AND tablename = 'financial_exceptions'
|
||||
AND policyname = 'financial_exceptions: tenant members read clinic rules'
|
||||
) THEN
|
||||
CREATE POLICY "financial_exceptions: tenant members read clinic rules"
|
||||
ON public.financial_exceptions
|
||||
FOR SELECT
|
||||
USING (
|
||||
owner_id IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM public.owner_users ou
|
||||
WHERE ou.owner_id = financial_exceptions.tenant_id
|
||||
AND ou.user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
GRANT ALL ON TABLE public.financial_exceptions TO postgres;
|
||||
GRANT ALL ON TABLE public.financial_exceptions TO anon;
|
||||
GRANT ALL ON TABLE public.financial_exceptions TO authenticated;
|
||||
GRANT ALL ON TABLE public.financial_exceptions TO service_role;
|
||||
@@ -0,0 +1,137 @@
|
||||
-- ============================================================
|
||||
-- MIGRATION 04: Recorrência com Serviços — Opção C
|
||||
-- 1. services_customized em agenda_eventos
|
||||
-- 2. Tabela recurrence_rule_services (template de serviços da regra)
|
||||
-- Data: 2026-03-12
|
||||
-- Idempotente: CREATE TABLE IF NOT EXISTS, CREATE INDEX IF NOT EXISTS,
|
||||
-- DROP POLICY IF EXISTS antes de CREATE POLICY
|
||||
-- ============================================================
|
||||
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────
|
||||
-- 1. ALTER: agenda_eventos — coluna services_customized
|
||||
-- Distingue ocorrências com serviços editados individualmente.
|
||||
-- DEFAULT false: todas as linhas existentes herdam do template da regra.
|
||||
-- ──────────────────────────────────────────────────────────────
|
||||
|
||||
ALTER TABLE public.agenda_eventos
|
||||
ADD COLUMN IF NOT EXISTS services_customized boolean DEFAULT false NOT NULL;
|
||||
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────
|
||||
-- 2. TABELA: recurrence_rule_services
|
||||
-- Template de serviços de uma regra de recorrência.
|
||||
-- Mesmo shape de commitment_services, mas ligado à regra (não ao evento).
|
||||
-- FKs inline no CREATE TABLE para idempotência total.
|
||||
-- ──────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.recurrence_rule_services (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
rule_id uuid NOT NULL
|
||||
REFERENCES public.recurrence_rules(id) ON DELETE CASCADE,
|
||||
service_id uuid NOT NULL
|
||||
REFERENCES public.services(id) ON DELETE RESTRICT,
|
||||
quantity integer DEFAULT 1 NOT NULL,
|
||||
unit_price numeric(10,2) NOT NULL,
|
||||
discount_pct numeric(5,2) DEFAULT 0,
|
||||
discount_flat numeric(10,2) DEFAULT 0,
|
||||
final_price numeric(10,2) NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
|
||||
CONSTRAINT recurrence_rule_services_pkey
|
||||
PRIMARY KEY (id),
|
||||
CONSTRAINT recurrence_rule_services_quantity_chk
|
||||
CHECK (quantity > 0),
|
||||
CONSTRAINT recurrence_rule_services_disc_pct_chk
|
||||
CHECK (discount_pct >= 0 AND discount_pct <= 100),
|
||||
CONSTRAINT recurrence_rule_services_disc_flat_chk
|
||||
CHECK (discount_flat >= 0),
|
||||
CONSTRAINT recurrence_rule_services_final_price_chk
|
||||
CHECK (final_price >= 0)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS recurrence_rule_services_rule_idx
|
||||
ON public.recurrence_rule_services USING btree (rule_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS recurrence_rule_services_service_idx
|
||||
ON public.recurrence_rule_services USING btree (service_id);
|
||||
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────
|
||||
-- 3. RLS: recurrence_rule_services
|
||||
-- Espelha as três policies de recurrence_rules:
|
||||
-- • owner full access — acesso direto via recurrence_rules.owner_id
|
||||
-- • clinic read — membro de clínica com feature agenda.view
|
||||
-- • clinic write — membro de clínica com feature agenda.edit
|
||||
-- ──────────────────────────────────────────────────────────────
|
||||
|
||||
ALTER TABLE public.recurrence_rule_services ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "recurrence_rule_services: owner full access"
|
||||
ON public.recurrence_rule_services;
|
||||
CREATE POLICY "recurrence_rule_services: owner full access"
|
||||
ON public.recurrence_rule_services
|
||||
TO authenticated
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.recurrence_rules r
|
||||
WHERE r.id = rule_id
|
||||
AND r.owner_id = auth.uid()
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.recurrence_rules r
|
||||
WHERE r.id = rule_id
|
||||
AND r.owner_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "recurrence_rule_services: clinic read"
|
||||
ON public.recurrence_rule_services;
|
||||
CREATE POLICY "recurrence_rule_services: clinic read"
|
||||
ON public.recurrence_rule_services
|
||||
FOR SELECT
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.recurrence_rules r
|
||||
WHERE r.id = rule_id
|
||||
AND public.is_clinic_tenant(r.tenant_id)
|
||||
AND public.is_tenant_member(r.tenant_id)
|
||||
AND public.tenant_has_feature(r.tenant_id, 'agenda.view')
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "recurrence_rule_services: clinic write"
|
||||
ON public.recurrence_rule_services;
|
||||
CREATE POLICY "recurrence_rule_services: clinic write"
|
||||
ON public.recurrence_rule_services
|
||||
FOR ALL
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.recurrence_rules r
|
||||
WHERE r.id = rule_id
|
||||
AND public.is_clinic_tenant(r.tenant_id)
|
||||
AND public.is_tenant_member(r.tenant_id)
|
||||
AND public.tenant_has_feature(r.tenant_id, 'agenda.edit')
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.recurrence_rules r
|
||||
WHERE r.id = rule_id
|
||||
AND public.is_clinic_tenant(r.tenant_id)
|
||||
AND public.is_tenant_member(r.tenant_id)
|
||||
AND public.tenant_has_feature(r.tenant_id, 'agenda.edit')
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────
|
||||
-- 4. GRANTs
|
||||
-- ──────────────────────────────────────────────────────────────
|
||||
|
||||
GRANT ALL ON TABLE public.recurrence_rule_services TO postgres;
|
||||
GRANT ALL ON TABLE public.recurrence_rule_services TO anon;
|
||||
GRANT ALL ON TABLE public.recurrence_rule_services TO authenticated;
|
||||
GRANT ALL ON TABLE public.recurrence_rule_services TO service_role;
|
||||
@@ -0,0 +1,67 @@
|
||||
-- ============================================================
|
||||
-- MIGRATION 05: Convênios (insurance_plans)
|
||||
-- Data: 2026-03-13
|
||||
-- ============================================================
|
||||
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────
|
||||
-- 1. TABELA: insurance_plans
|
||||
-- Planos de convênio cadastrados pelo profissional/clínica.
|
||||
-- ──────────────────────────────────────────────────────────────
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.insurance_plans (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
owner_id uuid NOT NULL,
|
||||
tenant_id uuid NOT NULL,
|
||||
name text NOT NULL,
|
||||
notes text,
|
||||
default_value numeric(10,2),
|
||||
active boolean DEFAULT true NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now(),
|
||||
updated_at timestamp with time zone DEFAULT now(),
|
||||
CONSTRAINT insurance_plans_pkey PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
ALTER TABLE public.insurance_plans
|
||||
ADD CONSTRAINT insurance_plans_owner_id_fkey
|
||||
FOREIGN KEY (owner_id) REFERENCES auth.users(id) ON DELETE CASCADE;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS insurance_plans_owner_idx ON public.insurance_plans(owner_id);
|
||||
CREATE INDEX IF NOT EXISTS insurance_plans_tenant_idx ON public.insurance_plans(tenant_id);
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.set_insurance_plans_updated_at()
|
||||
RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
BEGIN NEW.updated_at = now(); RETURN NEW; END; $$;
|
||||
|
||||
CREATE TRIGGER trg_insurance_plans_updated_at
|
||||
BEFORE UPDATE ON public.insurance_plans
|
||||
FOR EACH ROW EXECUTE FUNCTION public.set_insurance_plans_updated_at();
|
||||
|
||||
ALTER TABLE public.insurance_plans ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "insurance_plans: owner full access" ON public.insurance_plans;
|
||||
CREATE POLICY "insurance_plans: owner full access"
|
||||
ON public.insurance_plans
|
||||
USING (owner_id = auth.uid())
|
||||
WITH CHECK (owner_id = auth.uid());
|
||||
|
||||
GRANT ALL ON TABLE public.insurance_plans TO postgres, anon, authenticated, service_role;
|
||||
|
||||
|
||||
-- ──────────────────────────────────────────────────────────────
|
||||
-- 2. ALTER: agenda_eventos — campos de convênio
|
||||
-- ──────────────────────────────────────────────────────────────
|
||||
|
||||
ALTER TABLE public.agenda_eventos
|
||||
ADD COLUMN IF NOT EXISTS insurance_plan_id uuid,
|
||||
ADD COLUMN IF NOT EXISTS insurance_guide_number text,
|
||||
ADD COLUMN IF NOT EXISTS insurance_value numeric(10,2);
|
||||
|
||||
ALTER TABLE public.agenda_eventos
|
||||
ADD CONSTRAINT agenda_eventos_insurance_plan_id_fkey
|
||||
FOREIGN KEY (insurance_plan_id)
|
||||
REFERENCES public.insurance_plans(id)
|
||||
ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS agenda_eventos_insurance_plan_idx
|
||||
ON public.agenda_eventos(insurance_plan_id);
|
||||
@@ -0,0 +1,49 @@
|
||||
-- Migration: 06_insurance_plan_services
|
||||
-- Relacionamento entre convênios e serviços com valor tabelado por operadora
|
||||
|
||||
CREATE TABLE IF NOT EXISTS public.insurance_plan_services (
|
||||
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||
insurance_plan_id uuid NOT NULL,
|
||||
service_id uuid NOT NULL,
|
||||
value numeric(10,2) NOT NULL,
|
||||
created_at timestamptz DEFAULT now(),
|
||||
updated_at timestamptz DEFAULT now(),
|
||||
CONSTRAINT insurance_plan_services_pkey PRIMARY KEY (id),
|
||||
CONSTRAINT insurance_plan_services_plan_fkey
|
||||
FOREIGN KEY (insurance_plan_id) REFERENCES public.insurance_plans(id) ON DELETE CASCADE,
|
||||
CONSTRAINT insurance_plan_services_service_fkey
|
||||
FOREIGN KEY (service_id) REFERENCES public.services(id) ON DELETE CASCADE,
|
||||
CONSTRAINT insurance_plan_services_unique
|
||||
UNIQUE (insurance_plan_id, service_id)
|
||||
);
|
||||
|
||||
ALTER TABLE public.insurance_plan_services ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS "insurance_plan_services_owner" ON public.insurance_plan_services;
|
||||
CREATE POLICY "insurance_plan_services_owner"
|
||||
ON public.insurance_plan_services
|
||||
FOR ALL
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.insurance_plans ip
|
||||
WHERE ip.id = insurance_plan_services.insurance_plan_id
|
||||
AND ip.owner_id = auth.uid()
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
EXISTS (
|
||||
SELECT 1 FROM public.insurance_plans ip
|
||||
WHERE ip.id = insurance_plan_services.insurance_plan_id
|
||||
AND ip.owner_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
GRANT ALL ON TABLE public.insurance_plan_services TO postgres;
|
||||
GRANT ALL ON TABLE public.insurance_plan_services TO anon;
|
||||
GRANT ALL ON TABLE public.insurance_plan_services TO authenticated;
|
||||
GRANT ALL ON TABLE public.insurance_plan_services TO service_role;
|
||||
|
||||
DROP TRIGGER IF EXISTS set_insurance_plan_services_updated_at ON public.insurance_plan_services;
|
||||
CREATE TRIGGER set_insurance_plan_services_updated_at
|
||||
BEFORE UPDATE ON public.insurance_plan_services
|
||||
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
|
||||
Reference in New Issue
Block a user