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\" -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/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\" -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)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
20960
DBS/2026-03-12/schema.sql
Normal file
20960
DBS/2026-03-12/schema.sql
Normal file
File diff suppressed because it is too large
Load Diff
BIN
estrutura.txt
Normal file
BIN
estrutura.txt
Normal file
Binary file not shown.
Binary file not shown.
BIN
spec-v2.docx
Normal file
BIN
spec-v2.docx
Normal file
Binary file not shown.
BIN
spec-wizard.docx
Normal file
BIN
spec-wizard.docx
Normal file
Binary file not shown.
13
src/.claude/settings.local.json
Normal file
13
src/.claude/settings.local.json
Normal file
@@ -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>
|
||||||
<div v-if="isSessionEvent" class="summary-row">
|
<div v-if="isSessionEvent" class="summary-row">
|
||||||
<i class="pi pi-wallet summary-icon" />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── RECORRÊNCIA (só sessão) ───────────────────── -->
|
<!-- ── RECORRÊNCIA (só sessão) ───────────────────── -->
|
||||||
<div v-if="isSessionEvent" class="side-card">
|
<div v-if="isSessionEvent" class="side-card">
|
||||||
|
|
||||||
<!-- Valor da sessão -->
|
<!-- Serviços / Valor da sessão -->
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<FloatLabel variant="on">
|
|
||||||
<InputNumber
|
<!-- SelectButton Gratuito/Pago -->
|
||||||
v-model="form.price"
|
<SelectButton
|
||||||
inputId="aed-price-side"
|
v-model="billingType"
|
||||||
mode="currency"
|
:options="billingTypeOptions"
|
||||||
currency="BRL"
|
optionLabel="label"
|
||||||
locale="pt-BR"
|
optionValue="value"
|
||||||
:min="0"
|
class="mb-3 w-full"
|
||||||
:max="99999"
|
/>
|
||||||
:minFractionDigits="2"
|
|
||||||
fluid
|
<!-- 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>
|
</div>
|
||||||
</FloatLabel>
|
|
||||||
|
<!-- 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>
|
</div>
|
||||||
|
|
||||||
<!-- Observação -->
|
<!-- Observação -->
|
||||||
@@ -644,6 +793,22 @@
|
|||||||
<span class="text-xs">{{ totalConflitos }} sessão(ões) com conflito serão marcadas automaticamente para ajuste.</span>
|
<span class="text-xs">{{ totalConflitos }} sessão(ões) com conflito serão marcadas automaticamente para ajuste.</span>
|
||||||
</Message>
|
</Message>
|
||||||
</div>
|
</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>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -823,7 +988,11 @@
|
|||||||
optionGroupChildren="items"
|
optionGroupChildren="items"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
class="w-full"
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -875,7 +1044,10 @@ import Message from 'primevue/message'
|
|||||||
import { useConfirm } from 'primevue/useconfirm'
|
import { useConfirm } from 'primevue/useconfirm'
|
||||||
import { supabase } from '@/lib/supabase/client'
|
import { supabase } from '@/lib/supabase/client'
|
||||||
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue'
|
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) {
|
function patientInitials (nome) {
|
||||||
const parts = String(nome || '').trim().split(/\s+/).filter(Boolean)
|
const parts = String(nome || '').trim().split(/\s+/).filter(Boolean)
|
||||||
@@ -942,6 +1114,7 @@ const editScopeOptions = [
|
|||||||
{ value: 'somente_este', label: 'Somente esta sessão' },
|
{ value: 'somente_este', label: 'Somente esta sessão' },
|
||||||
{ value: 'este_e_seguintes', label: 'Esta e as seguintes' },
|
{ value: 'este_e_seguintes', label: 'Esta e as seguintes' },
|
||||||
{ value: 'todos', label: 'Todas da série' },
|
{ value: 'todos', label: 'Todas da série' },
|
||||||
|
{ value: 'todos_sem_excecao', label: 'Todas sem exceção' },
|
||||||
]
|
]
|
||||||
|
|
||||||
// ── recorrência (criação / sessão avulsa) ──────────────────
|
// ── recorrência (criação / sessão avulsa) ──────────────────
|
||||||
@@ -1110,24 +1283,167 @@ function isNativeSession (c) {
|
|||||||
|
|
||||||
const form = ref(resetForm())
|
const form = ref(resetForm())
|
||||||
|
|
||||||
// ── Precificação ────────────────────────────────────────────────────
|
// ── Precificação / Serviços ─────────────────────────────────────────
|
||||||
const { getPriceFor, load: loadPricing } = useProfessionalPricing()
|
const { services, getDefaultPrice, load: loadServices } = useServices()
|
||||||
let _pricingLoaded = false
|
const { loadItems: _csLoadItems, saveItems: saveCommitmentItems, loadItemsOrTemplate: _csLoadItemsOrTemplate } = useCommitmentServices()
|
||||||
|
const { loadActive: loadActiveDiscount } = usePatientDiscounts()
|
||||||
|
const { plans: insurancePlans, load: loadInsurancePlans } = useInsurancePlans()
|
||||||
|
let _servicesLoaded = false
|
||||||
|
|
||||||
async function ensurePricingLoaded () {
|
async function ensureServicesLoaded () {
|
||||||
if (_pricingLoaded || !props.ownerId) return
|
if (_servicesLoaded || !props.ownerId) return
|
||||||
_pricingLoaded = true
|
_servicesLoaded = true
|
||||||
await loadPricing(props.ownerId)
|
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)
|
// Só auto-preenche se price ainda não foi definido manualmente (ou é novo evento)
|
||||||
if (!isEdit.value) {
|
if (!isEdit.value) {
|
||||||
const suggested = getPriceFor(commitmentId)
|
const suggested = getDefaultPrice()
|
||||||
if (suggested != null) form.value.price = suggested
|
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 selectedCommitment = computed(() => {
|
||||||
const id = form.value.commitment_id
|
const id = form.value.commitment_id
|
||||||
if (!id) return null
|
if (!id) return null
|
||||||
@@ -1143,6 +1459,7 @@ const requiresPatient = computed(() => isNativeSession(selectedCommitment.value)
|
|||||||
const isSessionEvent = computed(() => requiresPatient.value)
|
const isSessionEvent = computed(() => requiresPatient.value)
|
||||||
// Bloqueia troca de paciente quando editando sessão que já tinha paciente vinculado
|
// 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 patientLocked = computed(() => isEdit.value && isSessionEvent.value && !!(props.eventRow?.paciente_id))
|
||||||
|
const hasInsurance = computed(() => !!form.value.insurance_plan_id)
|
||||||
|
|
||||||
// ── jornada ────────────────────────────────────────────────
|
// ── jornada ────────────────────────────────────────────────
|
||||||
function _fmtH (hhmm) {
|
function _fmtH (hhmm) {
|
||||||
@@ -1329,6 +1646,7 @@ watch(
|
|||||||
qtdSessoesMode.value = '4'
|
qtdSessoesMode.value = '4'
|
||||||
qtdSessoesCustom.value = 12
|
qtdSessoesCustom.value = 12
|
||||||
editScope.value = 'somente_este'
|
editScope.value = 'somente_este'
|
||||||
|
serieValorMode.value = 'multiplicar'
|
||||||
|
|
||||||
if (isEdit.value && form.value.paciente_id && !form.value.paciente_nome) {
|
if (isEdit.value && form.value.paciente_id && !form.value.paciente_nome) {
|
||||||
supabase
|
supabase
|
||||||
@@ -1355,8 +1673,26 @@ watch(
|
|||||||
clearPatientsCache()
|
clearPatientsCache()
|
||||||
if (requiresPatient.value) loadPatients(true)
|
if (requiresPatient.value) loadPatients(true)
|
||||||
|
|
||||||
// Pré-carrega precificação para auto-fill
|
// Pré-carrega serviços para auto-fill de preço
|
||||||
ensurePricingLoaded()
|
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,
|
() => form.value.commitment_id,
|
||||||
async (newId) => {
|
async (newId) => {
|
||||||
if (!newId || isEdit.value || !visible.value) return
|
if (!newId || isEdit.value || !visible.value) return
|
||||||
await ensurePricingLoaded()
|
await ensureServicesLoaded()
|
||||||
applyPriceForCommitment(newId)
|
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.startTime) return false
|
||||||
if (!form.value.commitment_id) return false
|
if (!form.value.commitment_id) return false
|
||||||
if (requiresPatient.value && !form.value.paciente_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
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1948,6 +2294,9 @@ function onSave () {
|
|||||||
titulo_custom: form.value.titulo_custom || null,
|
titulo_custom: form.value.titulo_custom || null,
|
||||||
extra_fields: Object.keys(form.value.extra_fields || {}).length ? form.value.extra_fields : null,
|
extra_fields: Object.keys(form.value.extra_fields || {}).length ? form.value.extra_fields : null,
|
||||||
price: isSessionEvent.value ? (form.value.price ?? null) : 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
|
// recorrência — só quando é sessão e não avulsa
|
||||||
@@ -1962,6 +2311,8 @@ function onSave () {
|
|||||||
duracaoMin: form.value.duracaoMin,
|
duracaoMin: form.value.duracaoMin,
|
||||||
dataFim: dataFimCalculada.value ? dataFimCalculada.value.toISOString() : null,
|
dataFim: dataFimCalculada.value ? dataFimCalculada.value.toISOString() : null,
|
||||||
qtdSessoes: qtdSessoesEfetiva.value,
|
qtdSessoes: qtdSessoesEfetiva.value,
|
||||||
|
serieValorMode: serieValorMode.value,
|
||||||
|
commitmentItems: commitmentItems.value.slice(),
|
||||||
}
|
}
|
||||||
recorrencia.conflitos = ocorrenciasComConflito.value
|
recorrencia.conflitos = ocorrenciasComConflito.value
|
||||||
.filter(o => o.conflict)
|
.filter(o => o.conflict)
|
||||||
@@ -1984,6 +2335,12 @@ function onSave () {
|
|||||||
original_date: emitOriginalDate,
|
original_date: emitOriginalDate,
|
||||||
// legado — mantido para compatibilidade
|
// legado — mantido para compatibilidade
|
||||||
serie_id: props.eventRow?.serie_id ?? null,
|
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,
|
conflito: null,
|
||||||
extra_fields: r?.extra_fields && typeof r.extra_fields === 'object' ? { ...r.extra_fields } : {},
|
extra_fields: r?.extra_fields && typeof r.extra_fields === 'object' ? { ...r.extra_fields } : {},
|
||||||
price: r?.price != null ? Number(r.price) : null,
|
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;
|
border-radius: 999px; padding: .1rem .45rem; flex-shrink: 0; white-space: nowrap;
|
||||||
}
|
}
|
||||||
.serie-pill__del { flex-shrink: 0; width: 2rem; }
|
.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>
|
</style>
|
||||||
@@ -33,6 +33,7 @@ const BASE_SELECT = `
|
|||||||
determined_commitment_id, link_online, extra_fields, modalidade,
|
determined_commitment_id, link_online, extra_fields, modalidade,
|
||||||
recurrence_id, recurrence_date,
|
recurrence_id, recurrence_date,
|
||||||
mirror_of_event_id, price,
|
mirror_of_event_id, price,
|
||||||
|
insurance_plan_id, insurance_guide_number, insurance_value,
|
||||||
patients!agenda_eventos_patient_id_fkey (
|
patients!agenda_eventos_patient_id_fkey (
|
||||||
id, nome_completo, avatar_url
|
id, nome_completo, avatar_url
|
||||||
),
|
),
|
||||||
|
|||||||
225
src/features/agenda/composables/useCommitmentServices.js
Normal file
225
src/features/agenda/composables/useCommitmentServices.js
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
103
src/features/agenda/composables/useFinancialExceptions.js
Normal file
103
src/features/agenda/composables/useFinancialExceptions.js
Normal file
@@ -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 }
|
||||||
|
}
|
||||||
83
src/features/agenda/composables/useInsurancePlans.js
Normal file
83
src/features/agenda/composables/useInsurancePlans.js
Normal file
@@ -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 }
|
||||||
|
}
|
||||||
118
src/features/agenda/composables/usePatientDiscounts.js
Normal file
118
src/features/agenda/composables/usePatientDiscounts.js
Normal file
@@ -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 || []) {
|
for (const row of storedRows || []) {
|
||||||
if (!row.recurrence_id || !row.recurrence_date) continue
|
if (!row.recurrence_id || !row.recurrence_date) continue
|
||||||
const key = `${row.recurrence_id}::${row.recurrence_date}`
|
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 = []
|
const result = []
|
||||||
|
|||||||
109
src/features/agenda/composables/useServices.js
Normal file
109
src/features/agenda/composables/useServices.js
Normal file
@@ -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"
|
:slotMinTime="slotMinTime"
|
||||||
:slotMaxTime="slotMaxTime"
|
:slotMaxTime="slotMaxTime"
|
||||||
:slotDuration="slotDuration"
|
:slotDuration="slotDuration"
|
||||||
|
:slotMinHeight="14"
|
||||||
|
:expandRows="false"
|
||||||
:businessHours="businessHours"
|
:businessHours="businessHours"
|
||||||
:staff="staffCols"
|
:staff="staffCols"
|
||||||
:events="allEvents"
|
:events="allEvents"
|
||||||
@@ -530,6 +532,7 @@ import { computed, onMounted, ref, watch, nextTick } from 'vue'
|
|||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
|
||||||
import { useToast } from 'primevue/usetoast'
|
import { useToast } from 'primevue/usetoast'
|
||||||
|
import { useConfirm } from 'primevue/useconfirm'
|
||||||
|
|
||||||
import Calendar from 'primevue/calendar'
|
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 { useAgendaClinicStaff } from '@/features/agenda/composables/useAgendaClinicStaff'
|
||||||
import { useAgendaClinicEvents } from '@/features/agenda/composables/useAgendaClinicEvents'
|
import { useAgendaClinicEvents } from '@/features/agenda/composables/useAgendaClinicEvents'
|
||||||
import { useRecurrence } from '@/features/agenda/composables/useRecurrence'
|
import { useRecurrence } from '@/features/agenda/composables/useRecurrence'
|
||||||
|
import { useCommitmentServices } from '@/features/agenda/composables/useCommitmentServices'
|
||||||
import { useDeterminedCommitments } from '@/features/agenda/composables/useDeterminedCommitments'
|
import { useDeterminedCommitments } from '@/features/agenda/composables/useDeterminedCommitments'
|
||||||
import { useFeriados } from '@/composables/useFeriados'
|
import { useFeriados } from '@/composables/useFeriados'
|
||||||
|
|
||||||
@@ -554,7 +558,8 @@ import { supabase } from '@/lib/supabase/client'
|
|||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const confirm = useConfirm()
|
||||||
const tenantStore = useTenantStore()
|
const tenantStore = useTenantStore()
|
||||||
|
|
||||||
// Data inicial via query param ?date=YYYY-MM-DD (vindo de "Ver na agenda")
|
// Data inicial via query param ?date=YYYY-MM-DD (vindo de "Ver na agenda")
|
||||||
@@ -931,6 +936,8 @@ const {
|
|||||||
upsertException,
|
upsertException,
|
||||||
} = useRecurrence()
|
} = useRecurrence()
|
||||||
|
|
||||||
|
const { saveRuleItems, propagateToSerie } = useCommitmentServices()
|
||||||
|
|
||||||
const EVENTO_TIPO = Object.freeze({ SESSAO: 'sessao', BLOQUEIO: 'bloqueio' })
|
const EVENTO_TIPO = Object.freeze({ SESSAO: 'sessao', BLOQUEIO: 'bloqueio' })
|
||||||
function normalizeEventoTipo (t, fallback = EVENTO_TIPO.SESSAO) {
|
function normalizeEventoTipo (t, fallback = EVENTO_TIPO.SESSAO) {
|
||||||
const s = String(t || '').trim().toLowerCase()
|
const s = String(t || '').trim().toLowerCase()
|
||||||
@@ -1064,7 +1071,7 @@ async function loadMonthSearchRows () {
|
|||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('agenda_eventos')
|
.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)
|
.eq('tenant_id', tid)
|
||||||
.in('owner_id', ids)
|
.in('owner_id', ids)
|
||||||
.is('mirror_of_event_id', null)
|
.is('mirror_of_event_id', null)
|
||||||
@@ -1207,6 +1214,8 @@ async function maybeLoadRange () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function onRangeChange ({ start, end, currentDate: cd }) {
|
async function onRangeChange ({ start, end, currentDate: cd }) {
|
||||||
|
const prevStart = pendingRange.value.start?.toString()
|
||||||
|
const prevEnd = pendingRange.value.end?.toString()
|
||||||
pendingRange.value = { start, end }
|
pendingRange.value = { start, end }
|
||||||
currentRange.value = { start, end }
|
currentRange.value = { start, end }
|
||||||
const base = cd || start || new Date()
|
const base = cd || start || new Date()
|
||||||
@@ -1218,7 +1227,14 @@ async function onRangeChange ({ start, end, currentDate: cd }) {
|
|||||||
miniDate.value = normalizeDay(newDate)
|
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() })
|
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,
|
determined_commitment_id: ep.determined_commitment_id ?? null,
|
||||||
titulo_custom: ep.titulo_custom ?? null,
|
titulo_custom: ep.titulo_custom ?? null,
|
||||||
extra_fields: ep.extra_fields ?? 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) ──────────────────────────
|
// ── recorrência (nova arquitetura) ──────────────────────────
|
||||||
recurrence_id: ep.recurrenceId ?? ep.recurrence_id ?? ep.serie_id ?? null,
|
recurrence_id: ep.recurrenceId ?? ep.recurrence_id ?? ep.serie_id ?? null,
|
||||||
original_date: ep.originalDate ?? ep.original_date ?? ep.recurrence_date ?? null,
|
original_date: ep.originalDate ?? ep.original_date ?? ep.recurrence_date ?? null,
|
||||||
@@ -1502,6 +1521,9 @@ function pickDbFields (obj) {
|
|||||||
'recurrence_id', 'recurrence_date',
|
'recurrence_id', 'recurrence_date',
|
||||||
// financeiro
|
// financeiro
|
||||||
'price',
|
'price',
|
||||||
|
'insurance_plan_id',
|
||||||
|
'insurance_guide_number',
|
||||||
|
'insurance_value',
|
||||||
]
|
]
|
||||||
const out = {}
|
const out = {}
|
||||||
for (const k of allowed) {
|
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) {
|
async function onDialogSave (arg) {
|
||||||
const tid = tenantId.value
|
const tid = tenantId.value
|
||||||
if (!tid) {
|
if (!tid) {
|
||||||
@@ -1654,32 +1719,93 @@ async function onDialogSave (arg) {
|
|||||||
await updateClinic(id, { recurrence_id: createdRule.id, recurrence_date: firstRecISO }, { tenantId: tid })
|
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'
|
const detail = recorrencia.qtdSessoes ? `${recorrencia.qtdSessoes} sessões criadas` : 'Série recorrente criada'
|
||||||
toast.add({ severity: 'success', summary: 'Série criada', detail, life: 3000 })
|
toast.add({ severity: 'success', summary: 'Série criada', detail, life: 3000 })
|
||||||
dialogOpen.value = false
|
dialogOpen.value = false
|
||||||
await _reloadRange()
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── CASO D: edição "somente_este" ──────────────────────────────────────
|
// ── CASO D: edição "somente_este" ──────────────────────────────────────
|
||||||
if (recurrenceId && editMode === '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({
|
await upsertException({
|
||||||
recurrence_id: recurrenceId,
|
recurrence_id: recurrenceId,
|
||||||
tenant_id: tid,
|
tenant_id: tid,
|
||||||
original_date: originalDate,
|
original_date: originalDate,
|
||||||
type: 'reschedule_session',
|
type: 'reschedule_session',
|
||||||
new_date: basePayload.inicio_em?.slice(0, 10),
|
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_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,
|
new_end_time: basePayload.fim_em ? new Date(basePayload.fim_em).toTimeString().slice(0, 8) : null,
|
||||||
modalidade: basePayload.modalidade ?? null,
|
modalidade: basePayload.modalidade ?? null,
|
||||||
titulo_custom: basePayload.titulo_custom ?? null,
|
titulo_custom: basePayload.titulo_custom ?? null,
|
||||||
observacoes: basePayload.observacoes ?? null,
|
observacoes: basePayload.observacoes ?? null,
|
||||||
extra_fields: basePayload.extra_fields ?? null,
|
extra_fields: basePayload.extra_fields ?? null,
|
||||||
})
|
})
|
||||||
} else if (id) {
|
if (arg.onSaved) {
|
||||||
await updateClinic(id, basePayload, { tenantId: tid })
|
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 })
|
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Sessão atualizada individualmente.', life: 2500 })
|
||||||
dialogOpen.value = false
|
dialogOpen.value = false
|
||||||
await _reloadRange()
|
await _reloadRange()
|
||||||
@@ -1700,6 +1826,15 @@ async function onDialogSave (arg) {
|
|||||||
observacoes: basePayload.observacoes ?? null,
|
observacoes: basePayload.observacoes ?? null,
|
||||||
extra_fields: basePayload.extra_fields ?? 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 })
|
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Esta sessão e as seguintes foram atualizadas.', life: 2500 })
|
||||||
dialogOpen.value = false
|
dialogOpen.value = false
|
||||||
await _reloadRange()
|
await _reloadRange()
|
||||||
@@ -1719,15 +1854,64 @@ async function onDialogSave (arg) {
|
|||||||
observacoes: basePayload.observacoes ?? null,
|
observacoes: basePayload.observacoes ?? null,
|
||||||
extra_fields: basePayload.extra_fields ?? 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 })
|
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Todas as sessões da série foram atualizadas.', life: 2500 })
|
||||||
dialogOpen.value = false
|
dialogOpen.value = false
|
||||||
await _reloadRange()
|
await _reloadRange()
|
||||||
return
|
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 ────────────────────────────
|
// ── CASO A/B: evento avulso ou sessão única ────────────────────────────
|
||||||
if (id) await updateClinic(id, basePayload, { tenantId: tid })
|
if (id) {
|
||||||
else await createClinic(basePayload, { tenantId: tid })
|
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
|
dialogOpen.value = false
|
||||||
await _reloadRange()
|
await _reloadRange()
|
||||||
@@ -1977,7 +2161,7 @@ async function loadMiniMonthEvents (refDate) {
|
|||||||
|
|
||||||
// 2. Ocorrências virtuais de recorrência (não existem no banco)
|
// 2. Ocorrências virtuais de recorrência (não existem no banco)
|
||||||
for (const oid of ownerIds.value || []) {
|
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 || []) {
|
for (const r of occRows || []) {
|
||||||
if (!r.inicio_em || !r.is_occurrence) continue
|
if (!r.inicio_em || !r.is_occurrence) continue
|
||||||
const ev = new Date(r.inicio_em)
|
const ev = new Date(r.inicio_em)
|
||||||
@@ -2014,7 +2198,7 @@ watch(
|
|||||||
() => loadMiniMonthEvents(miniDate.value),
|
() => loadMiniMonthEvents(miniDate.value),
|
||||||
{ immediate: true }
|
{ 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)
|
// Fix persistência: recarrega quando clinicOwnerId fica disponível (settings são async)
|
||||||
watch(clinicOwnerId, (v) => { if (v) loadMiniMonthEvents(miniDate.value) })
|
watch(clinicOwnerId, (v) => { if (v) loadMiniMonthEvents(miniDate.value) })
|
||||||
|
|
||||||
@@ -2276,6 +2460,15 @@ function goRecorrencias () { router.push({ name: 'admin-agenda-recorrencias' })
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<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 */
|
/* Mini calendário — colorir dias por expediente */
|
||||||
.p-datepicker-day.mini-day-work:not(.p-datepicker-day-selected) {
|
.p-datepicker-day.mini-day-work:not(.p-datepicker-day-selected) {
|
||||||
background: rgba(34, 197, 94, 0.25);
|
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 { useAgendaSettings } from '@/features/agenda/composables/useAgendaSettings'
|
||||||
import { useAgendaEvents } from '@/features/agenda/composables/useAgendaEvents'
|
import { useAgendaEvents } from '@/features/agenda/composables/useAgendaEvents'
|
||||||
import { useRecurrence } from '@/features/agenda/composables/useRecurrence'
|
import { useRecurrence } from '@/features/agenda/composables/useRecurrence'
|
||||||
|
import { useCommitmentServices } from '@/features/agenda/composables/useCommitmentServices'
|
||||||
import { useDeterminedCommitments } from '@/features/agenda/composables/useDeterminedCommitments'
|
import { useDeterminedCommitments } from '@/features/agenda/composables/useDeterminedCommitments'
|
||||||
import { useFeriados } from '@/composables/useFeriados'
|
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")
|
// 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 _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 ────────────────────────────────────────────────────
|
// ── Suporte técnico SaaS ────────────────────────────────────────────────────
|
||||||
const supportStore = useSupportDebugStore()
|
const supportStore = useSupportDebugStore()
|
||||||
@@ -636,6 +638,8 @@ const {
|
|||||||
upsertException,
|
upsertException,
|
||||||
} = useRecurrence()
|
} = useRecurrence()
|
||||||
|
|
||||||
|
const { saveRuleItems, propagateToSerie } = useCommitmentServices()
|
||||||
|
|
||||||
const ownerId = computed(() => settings.value?.owner_id || '')
|
const ownerId = computed(() => settings.value?.owner_id || '')
|
||||||
|
|
||||||
// -----------------------------
|
// -----------------------------
|
||||||
@@ -946,7 +950,7 @@ async function loadMonthSearchRows () {
|
|||||||
try {
|
try {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await supabase
|
||||||
.from('agenda_eventos')
|
.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)
|
.eq('owner_id', uid)
|
||||||
.is('mirror_of_event_id', null)
|
.is('mirror_of_event_id', null)
|
||||||
.gte('inicio_em', start)
|
.gte('inicio_em', start)
|
||||||
@@ -975,8 +979,13 @@ watch(currentDate, (newD, oldD) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const calendarEvents = computed(() => {
|
const calendarEvents = computed(() => {
|
||||||
// calendarRows já filtra onlySessions e une reais + virtuais
|
// separa reais e virtuais para aplicar mapAgendaEventosToCalendarEvents
|
||||||
const base = mapAgendaEventosToCalendarEvents(calendarRows.value)
|
// 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 =
|
const breaks =
|
||||||
settings.value && currentRange.value.start && currentRange.value.end
|
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(() => {
|
const visibleTitle = computed(() => {
|
||||||
@@ -1069,12 +1078,13 @@ const fcOptions = computed(() => ({
|
|||||||
snapDuration,
|
snapDuration,
|
||||||
slotLabelInterval,
|
slotLabelInterval,
|
||||||
slotLabelContent,
|
slotLabelContent,
|
||||||
expandRows: true,
|
expandRows: false,
|
||||||
height: 'auto',
|
height: 'auto',
|
||||||
|
slotMinHeight: 14,
|
||||||
|
|
||||||
dayMaxEvents: true,
|
dayMaxEvents: true,
|
||||||
weekends: true,
|
weekends: true,
|
||||||
eventMinHeight: 28,
|
eventMinHeight: 14,
|
||||||
|
|
||||||
businessHours: businessHours.value,
|
businessHours: businessHours.value,
|
||||||
events: calendarEvents.value,
|
events: calendarEvents.value,
|
||||||
@@ -1095,8 +1105,17 @@ const fcOptions = computed(() => ({
|
|||||||
const start = arg?.start
|
const start = arg?.start
|
||||||
const end = arg?.end
|
const end = arg?.end
|
||||||
if (start && end) {
|
if (start && end) {
|
||||||
|
const prevStart = currentRange.value.start?.toString()
|
||||||
|
const prevEnd = currentRange.value.end?.toString()
|
||||||
currentRange.value = { start, end }
|
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 })
|
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)
|
// 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 || []) {
|
for (const r of occRows || []) {
|
||||||
if (!r.inicio_em || !r.is_occurrence) continue
|
if (!r.inicio_em || !r.is_occurrence) continue
|
||||||
const ev = new Date(r.inicio_em)
|
const ev = new Date(r.inicio_em)
|
||||||
@@ -1309,7 +1328,7 @@ watch(
|
|||||||
() => loadMiniMonthEvents(miniDate.value),
|
() => loadMiniMonthEvents(miniDate.value),
|
||||||
{ immediate: true }
|
{ 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)
|
// Fix persistência: recarrega quando ownerId fica disponível (settings são async)
|
||||||
watch(ownerId, (v) => { if (v) loadMiniMonthEvents(miniDate.value) })
|
watch(ownerId, (v) => { if (v) loadMiniMonthEvents(miniDate.value) })
|
||||||
|
|
||||||
@@ -1555,7 +1574,10 @@ function onEventClick (info) {
|
|||||||
determined_commitment_id: ep.determined_commitment_id ?? null,
|
determined_commitment_id: ep.determined_commitment_id ?? null,
|
||||||
titulo_custom: ep.titulo_custom ?? null,
|
titulo_custom: ep.titulo_custom ?? null,
|
||||||
extra_fields: ep.extra_fields ?? 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) ──────────────────────────
|
// ── recorrência (nova arquitetura) ──────────────────────────
|
||||||
recurrence_id: ep.recurrence_id ?? ep.recurrenceId ?? ep.serie_id ?? null,
|
recurrence_id: ep.recurrence_id ?? ep.recurrenceId ?? ep.serie_id ?? null,
|
||||||
original_date: ep.original_date ?? ep.originalDate ?? ep.recurrence_date ?? null,
|
original_date: ep.original_date ?? ep.originalDate ?? ep.recurrence_date ?? null,
|
||||||
@@ -1647,6 +1669,9 @@ function pickDbFields (obj) {
|
|||||||
'recurrence_id', 'recurrence_date',
|
'recurrence_id', 'recurrence_date',
|
||||||
// financeiro
|
// financeiro
|
||||||
'price',
|
'price',
|
||||||
|
'insurance_plan_id',
|
||||||
|
'insurance_guide_number',
|
||||||
|
'insurance_value',
|
||||||
]
|
]
|
||||||
const out = {}
|
const out = {}
|
||||||
for (const k of allowed) {
|
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) {
|
async function onDialogSave (arg) {
|
||||||
let normalized = null
|
let normalized = null
|
||||||
|
|
||||||
@@ -1826,34 +1894,95 @@ async function onDialogSave (arg) {
|
|||||||
if (exErr) logError('AgendaTerapeutaPage', 'onDialogSave: erro ao inserir exceptions', exErr)
|
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
|
const detail = recorrencia.qtdSessoes
|
||||||
? `${recorrencia.qtdSessoes} sessões criadas`
|
? `${recorrencia.qtdSessoes} sessões criadas`
|
||||||
: 'Série recorrente criada'
|
: 'Série recorrente criada'
|
||||||
toast.add({ severity: 'success', summary: 'Série criada', detail, life: 3000 })
|
toast.add({ severity: 'success', summary: 'Série criada', detail, life: 3000 })
|
||||||
dialogOpen.value = false
|
dialogOpen.value = false
|
||||||
await _reloadRange()
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── CASO D: edição "somente_este" de ocorrência de série ───────────────
|
// ── CASO D: edição "somente_este" de ocorrência de série ───────────────
|
||||||
if (recurrenceId && editMode === '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 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({
|
await upsertException({
|
||||||
recurrence_id: recurrenceId,
|
recurrence_id: recurrenceId,
|
||||||
tenant_id: clinicId,
|
tenant_id: clinicId,
|
||||||
original_date: originalDate,
|
original_date: originalDate,
|
||||||
type: 'reschedule_session',
|
type: 'reschedule_session',
|
||||||
new_date: normalized.inicio_em?.slice(0, 10),
|
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_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,
|
new_end_time: normalized.fim_em ? new Date(normalized.fim_em).toTimeString().slice(0, 8) : null,
|
||||||
modalidade: normalized.modalidade ?? null,
|
modalidade: normalized.modalidade ?? null,
|
||||||
titulo_custom: normalized.titulo_custom ?? null,
|
titulo_custom: normalized.titulo_custom ?? null,
|
||||||
observacoes: normalized.observacoes ?? null,
|
observacoes: normalized.observacoes ?? null,
|
||||||
extra_fields: normalized.extra_fields ?? null,
|
extra_fields: normalized.extra_fields ?? null,
|
||||||
})
|
})
|
||||||
} else if (id) {
|
if (arg.onSaved) {
|
||||||
await update(id, pickDbFields(normalized))
|
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 })
|
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Sessão atualizada individualmente.', life: 2500 })
|
||||||
dialogOpen.value = false
|
dialogOpen.value = false
|
||||||
await _reloadRange()
|
await _reloadRange()
|
||||||
@@ -1874,6 +2003,15 @@ async function onDialogSave (arg) {
|
|||||||
observacoes: normalized.observacoes ?? null,
|
observacoes: normalized.observacoes ?? null,
|
||||||
extra_fields: normalized.extra_fields ?? 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 })
|
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Esta sessão e as seguintes foram atualizadas.', life: 2500 })
|
||||||
dialogOpen.value = false
|
dialogOpen.value = false
|
||||||
await _reloadRange()
|
await _reloadRange()
|
||||||
@@ -1893,20 +2031,66 @@ async function onDialogSave (arg) {
|
|||||||
observacoes: normalized.observacoes ?? null,
|
observacoes: normalized.observacoes ?? null,
|
||||||
extra_fields: normalized.extra_fields ?? 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 })
|
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Todas as sessões da série foram atualizadas.', life: 2500 })
|
||||||
dialogOpen.value = false
|
dialogOpen.value = false
|
||||||
await _reloadRange()
|
await _reloadRange()
|
||||||
return
|
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 ────────────────────────────
|
// ── CASO A/B: evento avulso ou sessão única ────────────────────────────
|
||||||
const dbPayload = pickDbFields(normalized)
|
const dbPayload = pickDbFields(normalized)
|
||||||
|
|
||||||
if (id) {
|
if (id) {
|
||||||
await update(id, dbPayload)
|
await update(id, dbPayload)
|
||||||
|
await arg.onSaved?.(id)
|
||||||
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Compromisso atualizado.', life: 2500 })
|
toast.add({ severity: 'success', summary: 'Salvo', detail: 'Compromisso atualizado.', life: 2500 })
|
||||||
} else {
|
} 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 })
|
toast.add({ severity: 'success', summary: 'Criado', detail: 'Compromisso criado.', life: 2500 })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2219,6 +2403,15 @@ onMounted(async () => {
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<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 ──────────────────────────── */
|
/* ── Slot labels customizados ──────────────────────────── */
|
||||||
.fc-slot-label-hour {
|
.fc-slot-label-hour {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
@@ -116,7 +116,10 @@ function _mapRow (r) {
|
|||||||
is_exception: r.is_exception ?? (exceptionType != null),
|
is_exception: r.is_exception ?? (exceptionType != null),
|
||||||
|
|
||||||
// financeiro
|
// 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
|
// timestamps
|
||||||
inicio_em: r.inicio_em,
|
inicio_em: r.inicio_em,
|
||||||
|
|||||||
@@ -31,7 +31,8 @@
|
|||||||
<!-- Controles desktop (≥1200px) -->
|
<!-- Controles desktop (≥1200px) -->
|
||||||
<div class="hidden xl:flex items-center gap-2 shrink-0">
|
<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" />
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Menu mobile (<1200px) -->
|
<!-- Menu mobile (<1200px) -->
|
||||||
@@ -413,7 +414,23 @@
|
|||||||
<Avatar v-if="data.avatar_url" :image="data.avatar_url" shape="square" size="large" />
|
<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" />
|
<Avatar v-else :label="initials(data.nome_completo)" shape="square" size="large" />
|
||||||
<div class="min-w-0">
|
<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>
|
<small class="text-color-secondary">ID: {{ shortId(data.id) }}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -509,7 +526,23 @@
|
|||||||
<Avatar v-if="pat.avatar_url" :image="pat.avatar_url" shape="square" size="large" />
|
<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" />
|
<Avatar v-else :label="initials(pat.nome_completo)" shape="square" size="large" />
|
||||||
<div class="flex-1 min-w-0">
|
<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 class="text-xs text-color-secondary">{{ fmtPhoneBR(pat.telefone) }} · {{ pat.email_principal || '—' }}</div>
|
||||||
</div>
|
</div>
|
||||||
<Tag :value="pat.status" :severity="pat.status === 'Ativo' ? 'success' : 'danger'" />
|
<Tag :value="pat.status" :severity="pat.status === 'Ativo' ? 'success' : 'danger'" />
|
||||||
@@ -632,10 +665,15 @@
|
|||||||
|
|
||||||
<div v-else class="sess-list">
|
<div v-else class="sess-list">
|
||||||
<div v-for="ev in sessoesLista" :key="ev.id" class="sess-item">
|
<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)" />
|
<Tag :value="ev.status || 'agendado'" :severity="statusSessaoSev(ev.status)" />
|
||||||
<span class="font-semibold text-sm">{{ fmtDataSessao(ev.inicio_em) }}</span>
|
<span class="font-semibold text-sm">{{ fmtDataSessao(ev.inicio_em) }}</span>
|
||||||
<Tag v-if="ev.modalidade" :value="ev.modalidade" severity="secondary" class="ml-auto" />
|
<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>
|
||||||
<div v-if="ev.titulo" class="text-xs text-color-secondary mt-1">{{ ev.titulo }}</div>
|
<div v-if="ev.titulo" class="text-xs text-color-secondary mt-1">{{ ev.titulo }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -655,6 +693,14 @@ import Popover from 'primevue/popover'
|
|||||||
import Menu from 'primevue/menu'
|
import Menu from 'primevue/menu'
|
||||||
import ProgressSpinner from 'primevue/progressspinner'
|
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 PatientProntuario from '@/features/patients/prontuario/PatientProntuario.vue'
|
||||||
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue'
|
import ComponentCadastroRapido from '@/components/ComponentCadastroRapido.vue'
|
||||||
@@ -686,7 +732,7 @@ async function abrirSessoes (pat) {
|
|||||||
const [evts, recs] = await Promise.all([
|
const [evts, recs] = await Promise.all([
|
||||||
supabase
|
supabase
|
||||||
.from('agenda_eventos')
|
.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)
|
.eq('patient_id', pat.id)
|
||||||
.order('inicio_em', { ascending: false })
|
.order('inicio_em', { ascending: false })
|
||||||
.limit(100),
|
.limit(100),
|
||||||
@@ -746,6 +792,8 @@ const patMobileMenuItems = [
|
|||||||
{ label: 'Cadastro Rápido', icon: 'pi pi-bolt', command: () => openQuickCreate() },
|
{ label: 'Cadastro Rápido', icon: 'pi pi-bolt', command: () => openQuickCreate() },
|
||||||
{ label: 'Cadastro Completo', icon: 'pi pi-file-edit', command: () => goCreateFull() },
|
{ label: 'Cadastro Completo', icon: 'pi pi-file-edit', command: () => goCreateFull() },
|
||||||
{ separator: true },
|
{ 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() }
|
{ label: 'Atualizar', icon: 'pi pi-refresh', command: () => fetchAll() }
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -870,6 +918,19 @@ onMounted(async () => {
|
|||||||
|
|
||||||
onBeforeUnmount(() => { _observer?.disconnect() })
|
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) {
|
function fmtPhoneBR(v) {
|
||||||
const d = String(v ?? '').replace(/\D/g, '')
|
const d = String(v ?? '').replace(/\D/g, '')
|
||||||
if (!d) return '—'
|
if (!d) return '—'
|
||||||
@@ -1144,6 +1205,41 @@ async function fetchAll() {
|
|||||||
const base = await listPatients()
|
const base = await listPatients()
|
||||||
patients.value = base
|
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()
|
groups.value = await listGroups()
|
||||||
console.log('[PatientsListPage] groups loaded:', groups.value)
|
console.log('[PatientsListPage] groups loaded:', groups.value)
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,30 @@ const secoes = [
|
|||||||
to: '/configuracoes/precificacao',
|
to: '/configuracoes/precificacao',
|
||||||
tags: ['Valores', 'Sessão', 'Compromisso']
|
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
|
// Ative quando criar as rotas/páginas
|
||||||
// {
|
// {
|
||||||
|
|||||||
343
src/layout/configuracoes/ConfiguracoesConveniosPage.vue
Normal file
343
src/layout/configuracoes/ConfiguracoesConveniosPage.vue
Normal file
@@ -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>
|
||||||
590
src/layout/configuracoes/ConfiguracoesDescontosPage.vue
Normal file
590
src/layout/configuracoes/ConfiguracoesDescontosPage.vue
Normal file
@@ -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 -->
|
<!-- src/layout/configuracoes/ConfiguracoesPrecificacaoPage.vue -->
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { supabase } from '@/lib/supabase/client'
|
import { supabase } from '@/lib/supabase/client'
|
||||||
import { useTenantStore } from '@/stores/tenantStore'
|
import { useTenantStore } from '@/stores/tenantStore'
|
||||||
import { useToast } from 'primevue/usetoast'
|
import { useToast } from 'primevue/usetoast'
|
||||||
|
import { useServices } from '@/features/agenda/composables/useServices'
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const tenantStore = useTenantStore()
|
const tenantStore = useTenantStore()
|
||||||
const loading = ref(true)
|
|
||||||
const saving = ref(false)
|
|
||||||
|
|
||||||
const ownerId = ref(null)
|
const { services, loading, error: servicesError, load, save, remove } = useServices()
|
||||||
const tenantId = ref(null)
|
|
||||||
|
|
||||||
// ── Tipos de compromisso do tenant ─────────────────────────────────
|
const ownerId = ref(null)
|
||||||
const commitments = ref([]) // [{ id, label, native_key }]
|
const tenantId = ref(null)
|
||||||
|
const slotMode = ref('fixed')
|
||||||
|
const pageLoading = ref(true)
|
||||||
|
|
||||||
// ── Preços: Map<commitmentId | '__default__', { price, notes }> ────
|
const isDynamic = computed(() => slotMode.value === 'dynamic')
|
||||||
// '__default__' = linha com determined_commitment_id IS NULL
|
|
||||||
const prices = ref({}) // { [key]: { price: number|null, notes: '' } }
|
|
||||||
|
|
||||||
// ── Carregar commitments do tenant ─────────────────────────────────
|
// ── Formulário novo serviço ──────────────────────────────────────────
|
||||||
async function loadCommitments () {
|
const emptyForm = () => ({ name: '', description: '', price: null, duration_min: null })
|
||||||
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')
|
|
||||||
|
|
||||||
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 ──────────────────────────────────────
|
function startEdit (svc) {
|
||||||
async function loadPrices (uid) {
|
editingId.value = svc.id
|
||||||
const { data, error } = await supabase
|
editForm.value = {
|
||||||
.from('professional_pricing')
|
id: svc.id,
|
||||||
.select('id, determined_commitment_id, price, notes')
|
owner_id: ownerId.value,
|
||||||
.eq('owner_id', uid)
|
tenant_id: tenantId.value,
|
||||||
|
name: svc.name,
|
||||||
if (error) throw error
|
description: svc.description ?? '',
|
||||||
|
price: svc.price != null ? Number(svc.price) : null,
|
||||||
const map = {}
|
duration_min: svc.duration_min ?? null,
|
||||||
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: '' }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 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 () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
const uid = tenantStore.user?.id || (await supabase.auth.getUser()).data?.user?.id
|
const uid = tenantStore.user?.id || (await supabase.auth.getUser()).data?.user?.id
|
||||||
@@ -73,78 +119,21 @@ onMounted(async () => {
|
|||||||
ownerId.value = uid
|
ownerId.value = uid
|
||||||
tenantId.value = tenantStore.activeTenantId || null
|
tenantId.value = tenantStore.activeTenantId || null
|
||||||
|
|
||||||
await Promise.all([
|
const { data: cfg } = await supabase
|
||||||
loadCommitments(),
|
.from('agenda_configuracoes')
|
||||||
loadPrices(uid),
|
.select('slot_mode')
|
||||||
])
|
.eq('owner_id', uid)
|
||||||
|
.maybeSingle()
|
||||||
|
|
||||||
ensureDefaults()
|
slotMode.value = cfg?.slot_mode ?? 'fixed'
|
||||||
|
|
||||||
|
await load(uid)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar.', life: 4000 })
|
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao carregar.', life: 4000 })
|
||||||
} finally {
|
} 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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -152,7 +141,7 @@ function fmtBRL (v) {
|
|||||||
|
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
|
|
||||||
<!-- Header card -->
|
<!-- Header -->
|
||||||
<Card>
|
<Card>
|
||||||
<template #content>
|
<template #content>
|
||||||
<div class="flex items-center justify-between gap-3 flex-wrap">
|
<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" />
|
<i class="pi pi-tag text-lg" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="text-900 font-semibold text-lg">Precificação</div>
|
<div class="text-900 font-semibold text-lg">Serviços e 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-600 text-sm">
|
||||||
|
Gerencie os serviços que você oferece e seus respectivos preços.
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
label="Salvar preços"
|
label="Novo serviço"
|
||||||
icon="pi pi-check"
|
icon="pi pi-plus"
|
||||||
:loading="saving"
|
:disabled="pageLoading || addingNew"
|
||||||
:disabled="loading"
|
@click="addingNew = true; cancelEdit()"
|
||||||
@click="save"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Loading -->
|
<!-- 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" />
|
<ProgressSpinner style="width:40px;height:40px" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
|
||||||
<!-- Preço padrão -->
|
<Message v-if="isDynamic" severity="info" :closable="false">
|
||||||
<Card>
|
<span class="text-sm">
|
||||||
<template #content>
|
Modo <b>dinâmico</b> ativo — a duração da sessão é definida pelo serviço selecionado.
|
||||||
<div class="flex flex-col gap-3">
|
</span>
|
||||||
<div class="flex items-center gap-2">
|
</Message>
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-12 gap-3 mt-1">
|
<!-- Formulário novo serviço -->
|
||||||
<div class="col-span-12 sm:col-span-5">
|
<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">
|
<FloatLabel variant="on">
|
||||||
<InputNumber
|
<InputNumber
|
||||||
v-model="prices['__default__'].price"
|
v-model="editForm.price"
|
||||||
inputId="price-default"
|
:inputId="`edit-price-${svc.id}`"
|
||||||
mode="currency"
|
mode="currency"
|
||||||
currency="BRL"
|
currency="BRL"
|
||||||
locale="pt-BR"
|
locale="pt-BR"
|
||||||
:min="0"
|
:min="0"
|
||||||
:max="99999"
|
|
||||||
:minFractionDigits="2"
|
:minFractionDigits="2"
|
||||||
fluid
|
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>
|
</FloatLabel>
|
||||||
</div>
|
</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">
|
<FloatLabel variant="on">
|
||||||
<InputText
|
<InputNumber v-model="editForm.duration_min" :inputId="`edit-dur-${svc.id}`" :min="1" :max="480" fluid />
|
||||||
v-model="prices['__default__'].notes"
|
<label :for="`edit-dur-${svc.id}`">Duração (min)</label>
|
||||||
inputId="notes-default"
|
</FloatLabel>
|
||||||
class="w-full"
|
</div>
|
||||||
placeholder="Ex: Particular, valor padrão"
|
<div class="col-span-12 sm:col-span-3">
|
||||||
/>
|
<FloatLabel variant="on">
|
||||||
<label for="notes-default">Observação (opcional)</label>
|
<InputText v-model="editForm.description" :inputId="`edit-desc-${svc.id}`" class="w-full" />
|
||||||
|
<label :for="`edit-desc-${svc.id}`">Descrição (opcional)</label>
|
||||||
</FloatLabel>
|
</FloatLabel>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="flex gap-2 justify-end mt-4">
|
||||||
</template>
|
<Button label="Cancelar" severity="secondary" outlined @click="cancelEdit" />
|
||||||
</Card>
|
<Button label="Salvar" icon="pi pi-check" :loading="savingEdit" @click="saveEdit" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Preços por tipo de compromisso -->
|
<!-- Modo leitura -->
|
||||||
<Card v-if="commitments.length">
|
<template v-else>
|
||||||
<template #title>
|
<div class="flex items-center justify-between gap-3 flex-wrap">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-3">
|
||||||
<i class="pi pi-list" />
|
<div class="cfg-icon-box-sm">
|
||||||
<span>Por tipo de compromisso</span>
|
<i class="pi pi-tag" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
<div>
|
||||||
<template #content>
|
<div class="font-semibold text-900">{{ svc.name }}</div>
|
||||||
<div class="text-600 text-sm mb-4">
|
<div class="text-sm text-color-secondary flex flex-wrap gap-x-3 gap-y-0.5 mt-0.5">
|
||||||
Valores específicos sobrepõem o preço padrão quando o tipo de compromisso coincide.
|
<span><b class="text-primary-500">{{ fmtBRL(svc.price) }}</b></span>
|
||||||
</div>
|
<span v-if="svc.duration_min">{{ svc.duration_min }}min</span>
|
||||||
|
<span v-if="svc.description" class="italic">{{ svc.description }}</span>
|
||||||
<div class="flex flex-col gap-4">
|
</div>
|
||||||
<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) }}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
<div class="grid grid-cols-12 gap-3 flex-1">
|
<Tag value="Ativo" severity="success" />
|
||||||
<div class="col-span-12 sm:col-span-5">
|
<Button icon="pi pi-pencil" severity="secondary" outlined size="small" v-tooltip.top="'Editar'" @click="startEdit(svc)" />
|
||||||
<FloatLabel variant="on">
|
<Button icon="pi pi-trash" severity="danger" outlined size="small" v-tooltip.top="'Remover'" @click="confirmRemove(svc.id)" />
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</template>
|
||||||
|
|
||||||
</template>
|
</template>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<!-- Dica -->
|
|
||||||
<Message severity="info" :closable="false">
|
<Message severity="info" :closable="false">
|
||||||
<span class="text-sm">
|
<span class="text-sm">
|
||||||
O preço configurado aqui é preenchido automaticamente ao criar uma sessão na agenda.
|
Os serviços cadastrados aqui aparecem para seleção ao criar ou editar um evento na agenda.
|
||||||
Você ainda pode ajustá-lo manualmente no diálogo de cada evento.
|
|
||||||
</span>
|
</span>
|
||||||
</Message>
|
</Message>
|
||||||
|
|
||||||
@@ -314,19 +337,14 @@ function fmtBRL (v) {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.commitment-row {
|
.cfg-icon-box-sm {
|
||||||
display: flex;
|
display: grid;
|
||||||
align-items: flex-start;
|
place-items: center;
|
||||||
gap: 1rem;
|
width: 2rem;
|
||||||
padding: 0.75rem;
|
height: 2rem;
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.625rem;
|
||||||
border: 1px solid var(--surface-border);
|
background: color-mix(in srgb, var(--p-primary-500, #6366f1) 10%, transparent);
|
||||||
background: var(--surface-ground);
|
color: var(--p-primary-500, #6366f1);
|
||||||
}
|
|
||||||
|
|
||||||
.commitment-label {
|
|
||||||
min-width: 9rem;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding-top: 0.5rem;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -41,6 +41,21 @@ const configuracoesRoutes = {
|
|||||||
path: 'precificacao',
|
path: 'precificacao',
|
||||||
name: 'ConfiguracoesPrecificacao',
|
name: 'ConfiguracoesPrecificacao',
|
||||||
component: () => import('@/layout/configuracoes/ConfiguracoesPrecificacaoPage.vue')
|
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')
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
245
src/sql-arquivos/02_services_pricing_billing.sql
Normal file
245
src/sql-arquivos/02_services_pricing_billing.sql
Normal file
@@ -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.';
|
||||||
160
src/sql-arquivos/03_patient_discounts_financial_exceptions.sql
Normal file
160
src/sql-arquivos/03_patient_discounts_financial_exceptions.sql
Normal file
@@ -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;
|
||||||
137
src/sql-arquivos/04_services_customized_flag.sql
Normal file
137
src/sql-arquivos/04_services_customized_flag.sql
Normal file
@@ -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;
|
||||||
67
src/sql-arquivos/05_insurance_plans.sql
Normal file
67
src/sql-arquivos/05_insurance_plans.sql
Normal file
@@ -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);
|
||||||
49
src/sql-arquivos/06_insurance_plan_services.sql
Normal file
49
src/sql-arquivos/06_insurance_plan_services.sql
Normal file
@@ -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