agenda: Fase 5 (status change/edit cobrada) + indicadores visuais + UX convenio

DB
- drop agenda_excecoes (substituida por financial_exceptions + lock-edit
  baseado em financial_records)
- financial_records.payment_link (Asaas + link compartilhavel)
- financial_exceptions.consume_on_miss (rotular nao-show consome ou nao)
- billing_contracts.charging_style (upfront/saldo/per_session)

Payment refactor
- paymentSettlement -> paymentMethod (string) + markPaidNow (bool).
  Handler aplica payment_method sempre; status='paid'+paid_at apenas
  quando markPaidNow=true && method != 'link'. Asaas (link) sempre
  liquida via webhook, nunca nasce paid.
- create_financial_record_for_session com pos-RPC patch pra payment_method
  e (opcional) status='paid' quando user marca "ja recebi".

Indicadores visuais (3 canais distintos por estado)
- Paid: barra esquerda emerald-500 4px na agenda (MelissaAgenda),
  pi-check-circle no popover/Resumo.
- Pending: badge \$ amber canto direito (mantido); linha amber no popover/
  Resumo "A receber R\$ X (cobranca pendente)".
- Neutro: sem badge nem barra (compromisso pessoal, bloqueio, ou
  ocorrencia virtual de pacote upfront/saldo).
- Bulk-load de paymentState em _reloadRange etapa 4 (1 query unica em
  financial_records mapeada por agenda_evento_id).
- AgendaEventDialog Resumo lateral ganha linha entre pi-clock e
  pi-map-marker via novo sessionPaymentRecord (sem guard de
  occurrenceMode, contrario ao occFinancialRecord que continua so pra
  Rail/Clinica). 5 estados: paid+paid_at, overdue+venceu, pending+vence,
  sem cobranca c/ valor, sem cobranca s/ valor.

UX de convenio
- InsurancePlanServiceQuickCreateDialog novo: cadastra procedimento
  POR CIMA do AgendaEventDialog sem sair da agenda. Auto-seleciona
  novo procedimento so quando nada estava selecionado antes.
- Caixa cinza "Cadastrar procedimento" sempre visivel quando convenio
  selecionado, com copy variavel (0 procedimentos: chamada urgente;
  1+: "se quiser adicionar mais").
- "+ Novo convenio" toolbar em ConfiguracoesConveniosPage (botao
  estava faltando, empty state mandava clicar em botao inexistente).
- Hint contextual abaixo do card Sessao/Honorarios: convenio = "N da
  guia eh opcional", gratuito = "sem cobranca", particular = sem hint.
  Label "N da Guia" tambem ganhou "(opcional)" no service-picker dialog.

Bug fixes
- pickDbFields whitelist faltando 'modalidade' (useMelissaAgenda.js:74)
  — sessoes avulsas eram salvas como presencial independente da
  escolha visual. Adicionado.
- goToConveniosConfig removida — fazia router.push("/therapist/
  configuracoes/convenios") mas /configuracoes/* eh rota raiz, nao
  filha. Substituida pelo quick-create inline (#1).
- bloqueioCobrindo + dialogBlockOverlap passados via deps em
  _buildHandlers (refs do useMelissaAgenda nao sao acessiveis no
  escopo de _buildHandlers).

Fase 5 (status change + edit cobrada)
- AgendaStatusChangeConfirmDialog: confirm dialog quando user muda
  status pra realizada/faltou/cancelado, com opcoes de markPaid ou
  gerar cobranca conforme o caso.
- useAgendaBloqueios novo composable: extrai logica de bloqueios
  cinza (background events) do MelissaAgenda.

Doc viva
- src/docs/agenda-compromisso-financeiro-cenarios.html: 13 cenarios
  de teste manual. C1-C4 ja validados. Cada teste validado vira parte
  da doc final pra area de ajuda (pos-Fase 9).

Wiki/handoff
- agenda-compromisso-fluxo e agenda-billing-pesquisa-mercado (decisoes
  arquiteturais sobre billing).
- HANDOFF.md atualizado.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Leonardo
2026-05-19 08:31:18 -03:00
parent 41c44272a3
commit e95ed9b585
41 changed files with 8715 additions and 852 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,474 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/features/agenda/components/AgendaStatusChangeConfirmDialog.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<script setup>
/*
* AgendaStatusChangeConfirmDialog — Dialog que aparece ao mudar status
* de uma sessão (realizado/faltou/cancelado). Mostra defaults vindos da
* config (financial_exceptions, billing_contracts) e permite override
* caso a caso pelo terapeuta/clínica antes de aplicar.
*
* 5 variantes de renderização baseadas em (novoStatus, eventoContext):
* - faltou/cancelado + avulsa → bloco multa
* - faltou/cancelado + pacote saldo → bloco saldo + bloco multa
* - faltou/cancelado + pacote upfront → bloco multa (saldo n/a)
* - realizado + avulsa pendente → bloco "registrar pagamento"
* - realizado + pacote saldo → bloco "gerar cobrança no pacote"
*
* Emit 'confirm' com objeto descrevendo o que o handler deve fazer:
* {
* consumeSaldo: bool, // só relevante em pacote saldo + faltou/cancelado
* applyFine: bool, // se vai cobrar multa
* fineAmount: number|null, // valor da multa (editavel)
* markPaid: bool, // realizado avulsa → marcar paga
* paymentMethod: string, // método se markPaid ou gerar cobrança
* generatePackageCharge: bool // realizado em pacote saldo → criar record
* }
*/
import { ref, computed, watch } from 'vue';
const props = defineProps({
modelValue: { type: Boolean, default: false },
evento: { type: Object, default: null },
novoStatus: { type: String, default: '' }, // 'realizado' | 'faltou' | 'cancelado'
regraExcecao: { type: Object, default: null }, // row de financial_exceptions ou null
billingContract: { type: Object, default: null }, // row de billing_contracts ou null
billingContractStyle: { type: String, default: null }, // 'upfront' | 'saldo' | null
// Quando avulsa+pendente e novoStatus='realizado': financial_record relacionado
pendingRecord: { type: Object, default: null },
// Preço da sessão (pra calcular multa percentual e cobrança de pacote saldo)
sessionPrice: { type: Number, default: 0 }
});
const emit = defineEmits(['update:modelValue', 'confirm']);
// ─── State interno (refs editáveis) ─────────────────────────────────────
const consumeSaldo = ref(false);
const applyFine = ref(false);
const fineAmount = ref(0);
const markPaid = ref(true); // default em realizado: já recebeu
const paymentMethod = ref('pix'); // método em "como recebeu" ou "como cobrar"
const generatePackageCharge = ref(true); // realizado em pacote saldo: gera por padrão
// Reset/init ao abrir
watch(
() => props.modelValue,
(open) => {
if (!open) return;
// Defaults baseados em context
consumeSaldo.value = !!props.regraExcecao?.default_consume_on_miss;
applyFine.value = _calcInitialFineApply();
fineAmount.value = _calcInitialFineAmount();
markPaid.value = true;
paymentMethod.value = 'pix';
generatePackageCharge.value = true;
}
);
// ─── Computeds: o que renderizar ────────────────────────────────────────
const isFaltouOrCancelado = computed(() => props.novoStatus === 'faltou' || props.novoStatus === 'cancelado');
const isRealizado = computed(() => props.novoStatus === 'realizado');
const isAvulsa = computed(() => !props.billingContract);
const isPacoteSaldo = computed(() => props.billingContract?.type === 'package' && props.billingContractStyle === 'saldo');
const isPacoteUpfront = computed(() => props.billingContract?.type === 'package' && props.billingContractStyle === 'upfront');
// Mostrar bloco multa: faltou/cancelado + regra existe + charge_mode != 'none'
const showFineBlock = computed(() => isFaltouOrCancelado.value && props.regraExcecao && props.regraExcecao.charge_mode !== 'none');
// Mostrar bloco saldo: faltou/cancelado + pacote saldo
const showSaldoBlock = computed(() => isFaltouOrCancelado.value && isPacoteSaldo.value);
// Mostrar bloco "registrar pagamento": realizado + avulsa pendente
const showRegistrarPagto = computed(() => isRealizado.value && isAvulsa.value && props.pendingRecord && props.pendingRecord.status === 'pending');
// Mostrar bloco "cobrança no pacote": realizado + pacote saldo
const showCobrancaPacote = computed(() => isRealizado.value && isPacoteSaldo.value);
// ─── Header ────────────────────────────────────────────────────────────
const headerTitle = computed(() => {
const labels = { realizado: '✓ Marcar como Realizado', faltou: '⚠ Marcar como Faltou', cancelado: '✕ Marcar como Cancelado' };
return labels[props.novoStatus] || 'Atualizar status';
});
// ─── Sub-info do evento ────────────────────────────────────────────────
const pacienteNome = computed(() => props.evento?.paciente_nome || props.evento?.patients?.nome_completo || 'Paciente');
const dataHora = computed(() => {
const ini = props.evento?.inicio_em;
if (!ini) return '';
try {
const d = new Date(ini);
return d.toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' });
} catch {
return '';
}
});
const isSerieSemPacote = computed(() => {
// Série recorrente que não criou billing_contract (chargeMode='none' no save).
// Detecta via recurrence_id/serie_id na row sem contract carregado.
if (props.billingContract) return false;
const e = props.evento;
return !!(e?.recurrence_id || e?.serie_id || e?.is_occurrence);
});
const tipoTag = computed(() => {
if (isPacoteSaldo.value) return `Pacote saldo: ${props.billingContract.sessions_used ?? 0} de ${props.billingContract.total_sessions ?? '?'} usadas`;
if (isPacoteUpfront.value) return `Pacote upfront: R$ ${_fmtBRL(props.billingContract.package_price)} pago`;
if (isSerieSemPacote.value) return 'Série recorrente (sem pacote)';
if (props.pendingRecord) return `Avulsa: R$ ${_fmtBRL(props.pendingRecord.final_amount || props.pendingRecord.amount)} pendente`;
return 'Avulsa';
});
// ─── Labels e options ──────────────────────────────────────────────────
const paymentMethodOptions = [
{ value: 'pix', label: 'PIX' },
{ value: 'dinheiro', label: 'Dinheiro' },
{ value: 'deposito', label: 'Depósito' },
{ value: 'cartao_maquininha', label: 'Cartão (maquininha)' }
];
const paymentMethodOptionsCobranca = [
{ value: 'link', label: 'Enviar link de pagamento (Asaas)' },
{ value: 'pix', label: 'Já recebi — PIX' },
{ value: 'dinheiro', label: 'Já recebi — Dinheiro' },
{ value: 'deposito', label: 'Já recebi — Depósito' },
{ value: 'cartao_maquininha', label: 'Já recebi — Cartão (maquininha)' }
];
const regraResumo = computed(() => {
const r = props.regraExcecao;
if (!r) return '';
if (r.charge_mode === 'full') return `Sessão completa = R$ ${_fmtBRL(props.sessionPrice)}`;
if (r.charge_mode === 'fixed_fee') return `Taxa fixa: R$ ${_fmtBRL(r.charge_value)}`;
if (r.charge_mode === 'percentage') return `${r.charge_pct}% da sessão = R$ ${_fmtBRL((props.sessionPrice * (r.charge_pct ?? 0)) / 100)}`;
return 'Sem cobrança';
});
// ─── Helpers ───────────────────────────────────────────────────────────
function _fmtBRL(v) {
return Number(v ?? 0).toLocaleString('pt-BR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function _calcInitialFineAmount() {
const r = props.regraExcecao;
if (!r) return 0;
if (r.charge_mode === 'full') return Number(props.sessionPrice) || 0;
if (r.charge_mode === 'fixed_fee') return Number(r.charge_value) || 0;
if (r.charge_mode === 'percentage') return parseFloat((((Number(props.sessionPrice) || 0) * (Number(r.charge_pct) || 0)) / 100).toFixed(2));
return 0;
}
function _calcInitialFineApply() {
// Se regra existe e charge_mode != 'none': default true
// Exceção: cancelado fora da janela min_hours_notice → default false (paciente cancelou dentro do prazo)
const r = props.regraExcecao;
if (!r || r.charge_mode === 'none') return false;
if (props.novoStatus === 'cancelado' && r.min_hours_notice != null && props.evento?.inicio_em) {
const horasAteSessao = (new Date(props.evento.inicio_em).getTime() - Date.now()) / (1000 * 60 * 60);
// Se cancelou COM MAIS antecedência que min → sem multa por padrão
if (horasAteSessao >= Number(r.min_hours_notice)) return false;
}
return true;
}
// ─── Actions ───────────────────────────────────────────────────────────
function onConfirm() {
emit('confirm', {
consumeSaldo: showSaldoBlock.value ? consumeSaldo.value : false,
applyFine: showFineBlock.value ? applyFine.value : false,
fineAmount: showFineBlock.value && applyFine.value ? Number(fineAmount.value) || 0 : 0,
markPaid: showRegistrarPagto.value ? markPaid.value : false,
paymentMethod: paymentMethod.value,
generatePackageCharge: showCobrancaPacote.value ? generatePackageCharge.value : false
});
emit('update:modelValue', false);
}
function onCancel() {
emit('update:modelValue', false);
}
</script>
<template>
<Dialog
:visible="modelValue"
modal
:draggable="false"
:style="{ width: '480px', maxWidth: '96vw' }"
:breakpoints="{ '640px': '98vw' }"
@update:visible="emit('update:modelValue', $event)"
>
<template #header>
<div class="asccd-head">
<div class="asccd-head__title">{{ headerTitle }}</div>
</div>
</template>
<div class="flex flex-col gap-4">
<!-- Resumo do evento -->
<div class="asccd-summary">
<div class="asccd-summary__name">{{ pacienteNome }}</div>
<div class="asccd-summary__meta">
<span v-if="dataHora">{{ dataHora }}</span>
<span class="asccd-summary__tag">{{ tipoTag }}</span>
</div>
</div>
<!-- Bloco SALDO (pacote saldo + faltou/cancelado) -->
<div v-if="showSaldoBlock" class="asccd-block">
<div class="asccd-block__title">
<i class="pi pi-wallet" />
O que fazer com a vaga do pacote?
</div>
<div class="asccd-radio-group">
<label class="asccd-radio">
<input type="radio" :value="false" v-model="consumeSaldo" />
<span><b>Remarcar</b> não consome saldo (paciente pode remarcar)</span>
</label>
<label class="asccd-radio">
<input type="radio" :value="true" v-model="consumeSaldo" />
<span><b>Descontar</b> sessão perdida ({{ billingContract?.sessions_used ?? 0 }} {{ (billingContract?.sessions_used ?? 0) + 1 }})</span>
</label>
</div>
</div>
<!-- Bloco MULTA (faltou/cancelado com regra configurada) -->
<div v-if="showFineBlock" class="asccd-block">
<div class="asccd-block__title">
<i class="pi pi-money-bill" />
Aplicar multa?
</div>
<div class="asccd-fine-rule">Regra atual: {{ regraResumo }}</div>
<div class="asccd-fine-row">
<Checkbox v-model="applyFine" inputId="asccd-apply-fine" binary />
<label for="asccd-apply-fine" class="cursor-pointer">Aplicar multa</label>
<InputNumber
v-model="fineAmount"
:disabled="!applyFine"
mode="currency"
currency="BRL"
locale="pt-BR"
:min="0"
size="small"
class="asccd-fine-input"
/>
</div>
<small v-if="isPacoteUpfront" class="asccd-hint">
Pacote pago; multa entra como cobrança adicional avulsa.
</small>
</div>
<!-- Pacote upfront sem multa configurada: aviso -->
<div v-if="isFaltouOrCancelado && isPacoteUpfront && !showFineBlock" class="asccd-info">
<i class="pi pi-info-circle" />
Pacote foi pago. Nenhuma multa configurada atualiza o status.
</div>
<!-- Bloco REGISTRAR PAGAMENTO (realizado + avulsa pendente) -->
<div v-if="showRegistrarPagto" class="asccd-block">
<div class="asccd-block__title">
<i class="pi pi-check-circle" />
A sessão foi paga?
</div>
<div class="asccd-radio-group">
<label class="asccd-radio">
<input type="radio" :value="false" v-model="markPaid" />
<span>Não, manter cobrança pendente</span>
</label>
<label class="asccd-radio">
<input type="radio" :value="true" v-model="markPaid" />
<span>Sim, registrar pagamento</span>
</label>
</div>
<div v-if="markPaid" class="asccd-method-row">
<label class="asccd-method-label">Como recebeu?</label>
<Select
v-model="paymentMethod"
:options="paymentMethodOptions"
optionLabel="label"
optionValue="value"
size="small"
class="asccd-method-select"
/>
</div>
</div>
<!-- Bloco COBRANÇA NO PACOTE (realizado + pacote saldo) -->
<div v-if="showCobrancaPacote" class="asccd-block">
<div class="asccd-block__title">
<i class="pi pi-money-bill" />
Gerar cobrança no pacote?
</div>
<div class="asccd-fine-rule">Valor da sessão: R$ {{ _fmtBRL(sessionPrice) }}</div>
<div class="asccd-fine-row">
<Checkbox v-model="generatePackageCharge" inputId="asccd-gen-charge" binary />
<label for="asccd-gen-charge" class="cursor-pointer">Gerar cobrança e consumir 1 sessão</label>
</div>
<div v-if="generatePackageCharge" class="asccd-method-row">
<label class="asccd-method-label">Como cobrar?</label>
<Select
v-model="paymentMethod"
:options="paymentMethodOptionsCobranca"
optionLabel="label"
optionValue="value"
size="small"
class="asccd-method-select"
/>
</div>
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined size="small" @click="onCancel" />
<Button label="Confirmar" icon="pi pi-check" size="small" @click="onConfirm" />
</template>
</Dialog>
</template>
<style scoped>
.asccd-head__title {
font-size: 1rem;
font-weight: 700;
color: var(--text-color);
}
.asccd-summary {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.65rem 0.85rem;
border-radius: 6px;
background: color-mix(in srgb, var(--p-primary-500) 4%, var(--surface-card));
border: 1px solid var(--surface-border);
}
.asccd-summary__name {
font-size: 0.92rem;
font-weight: 700;
color: var(--text-color);
}
.asccd-summary__meta {
font-size: 0.74rem;
color: var(--text-color-secondary);
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.asccd-summary__tag {
display: inline-block;
padding: 1px 7px;
border-radius: 999px;
background: color-mix(in srgb, var(--p-primary-500) 12%, transparent);
color: var(--p-primary-color);
font-weight: 600;
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.asccd-block {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem 0.85rem;
border-radius: 6px;
border: 1px solid var(--surface-border);
background: var(--surface-card);
}
.asccd-block__title {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-color);
}
.asccd-block__title i {
color: var(--p-primary-color);
font-size: 0.85rem;
}
.asccd-radio-group {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.asccd-radio {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.82rem;
color: var(--text-color);
cursor: pointer;
}
.asccd-radio input[type='radio'] {
accent-color: var(--p-primary-color);
cursor: pointer;
}
.asccd-fine-rule {
font-size: 0.74rem;
color: var(--text-color-secondary);
font-style: italic;
}
.asccd-fine-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.asccd-fine-input {
flex: 1;
min-width: 8rem;
}
.asccd-method-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.25rem;
}
.asccd-method-label {
font-size: 0.78rem;
color: var(--text-color);
font-weight: 500;
flex-shrink: 0;
}
.asccd-method-select {
flex: 1;
min-width: 12rem;
}
.asccd-info {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.85rem;
border-radius: 6px;
background: color-mix(in srgb, var(--blue-400, #60a5fa) 8%, var(--surface-card));
border: 1px solid color-mix(in srgb, var(--blue-400, #60a5fa) 30%, transparent);
font-size: 0.78rem;
color: var(--text-color);
}
.asccd-info i {
color: var(--blue-600, #2563eb);
flex-shrink: 0;
}
.asccd-hint {
font-size: 0.7rem;
color: var(--text-color-secondary);
font-style: italic;
}
</style>
@@ -93,7 +93,6 @@ async function onSave() {
:closable="!saving"
header="Novo convênio"
class="w-[94vw] max-w-md"
pt:mask:class="backdrop-blur-sm"
>
<div class="flex flex-col gap-3 pt-1">
<FloatLabel variant="on">
@@ -0,0 +1,130 @@
<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Arquivo: src/features/agenda/components/InsurancePlanServiceQuickCreateDialog.vue
| Data: 2026-05-18
|
| Mini-dialog pra cadastrar procedimento dentro de um convênio SEM sair
| do AgendaEventDialog. Mesmo pattern do InsurancePlanQuickCreateDialog
| emite `created` com a row inserida; o parent recarrega os planos e
| (opcionalmente) auto-seleciona o novo procedimento.
|
| Campos:
| name * Ex: "Consulta psicológica", "Avaliação", "Sessão online"
| value * Valor que o convênio paga pra clínica nesse procedimento
|--------------------------------------------------------------------------
-->
<script setup>
import { ref, watch } from 'vue';
import { useToast } from 'primevue/usetoast';
import { supabase } from '@/lib/supabase/client';
const props = defineProps({
modelValue: { type: Boolean, default: false },
insurancePlanId: { type: String, default: '' },
insurancePlanName: { type: String, default: '' }
});
const emit = defineEmits(['update:modelValue', 'created']);
const toast = useToast();
const visible = ref(props.modelValue);
watch(() => props.modelValue, (v) => { visible.value = v; });
watch(visible, (v) => emit('update:modelValue', v));
const form = ref({
name: '',
value: null
});
const saving = ref(false);
watch(() => props.modelValue, (v) => {
if (v) {
form.value = { name: '', value: null };
}
});
const canSave = () => !!form.value.name?.trim() && form.value.value != null && form.value.value > 0;
async function onSave() {
if (!canSave()) return;
if (!props.insurancePlanId) {
toast.add({ severity: 'error', summary: 'Convênio ausente', detail: 'Selecione um convênio antes de cadastrar procedimento.', life: 3500 });
return;
}
saving.value = true;
try {
const payload = {
insurance_plan_id: props.insurancePlanId,
name: form.value.name.trim().slice(0, 120),
value: Number(form.value.value),
active: true
};
const { data, error } = await supabase.from('insurance_plan_services').insert(payload).select().single();
if (error) throw error;
toast.add({ severity: 'success', summary: 'Procedimento cadastrado', life: 2200 });
emit('created', data);
visible.value = false;
} catch (e) {
toast.add({ severity: 'error', summary: 'Falha ao criar procedimento', detail: e?.message || 'Erro inesperado', life: 4000 });
} finally {
saving.value = false;
}
}
</script>
<template>
<Dialog
v-model:visible="visible"
modal
:draggable="false"
:closable="!saving"
class="w-[94vw] max-w-md"
>
<template #header>
<div class="flex flex-col">
<span class="text-base font-semibold">Novo procedimento</span>
<span v-if="insurancePlanName" class="text-xs text-color-secondary">{{ insurancePlanName }}</span>
</div>
</template>
<div class="flex flex-col gap-3 pt-1">
<FloatLabel variant="on">
<InputText id="ips-name" v-model="form.name" class="w-full" autofocus maxlength="120" />
<label for="ips-name">Nome do procedimento *</label>
</FloatLabel>
<FloatLabel variant="on">
<InputNumber
id="ips-value"
v-model="form.value"
mode="currency"
currency="BRL"
locale="pt-BR"
:min="0"
:max="999999"
class="w-full"
/>
<label for="ips-value">Valor que o convênio paga *</label>
</FloatLabel>
<p class="text-xs text-color-secondary -mt-1">
<i class="pi pi-info-circle mr-1" />
Esse é o valor que a clínica recebe não o que o paciente paga.
</p>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" :disabled="saving" @click="visible = false" />
<Button
label="Cadastrar"
icon="pi pi-check"
:loading="saving"
:disabled="!canSave()"
class="rounded-full"
@click="onSave"
/>
</template>
</Dialog>
</template>
@@ -110,7 +110,6 @@ async function onSave() {
:closable="!saving"
header="Novo serviço"
class="w-[94vw] max-w-md"
pt:mask:class="backdrop-blur-sm"
>
<div class="flex flex-col gap-3 pt-1">
<FloatLabel variant="on">