Files
agenciapsilmno/src/components/agenda/AgendaEventoFinanceiroPanel.vue
T
Leonardo d240c6678f F6.2 Lote D: RPCs user-facing roteadas pro schema do tenant
DB (supabase_admin, manual/f6_2d_user_rpcs.supabase_admin.sql): 14 RPCs.
Helper _tenant_route(p_tenant_id) valida is_tenant_member + retorna schema
(retorna, nao seta — set_config em helper com SET search_path proprio seria
revertido na saida). Cada RPC: set_config search_path pro schema + unqualify
tabelas tenant + remove WHERE tenant_id= e tenant_id de inserts.
- Grupo 1 (ja tinham p_tenant_id, jsonb/void): delete_commitment_full,
  delete_determined_commitment, seed_default_patient_groups,
  seed_determined_commitments (no-op se schema nao existe)
- Grupo 2 (novo p_tenant_id 1o param, DROP+CREATE): cancel_recurrence_from,
  cancelar_eventos_serie, split_recurrence_at, safe_delete_patient,
  export_patient_data (audit_logs global mantido), search_global
  (patient_intake_requests fica em public/F1b -> qualificado + filtro tenant_id)
- Grupo 3 (RETURNS <tabela>->jsonb): mark_as_paid, create_financial_record_
  for_session, mark_payout_as_paid, create_therapist_payout
- can_delete_patient: unqualified, herda search_path do chamador
Smoke: mark_as_paid (status=paid, jsonb) + search_global (acha paciente) OK.

Frontend (18 sites): p_tenant_id de useTenantStore().activeTenantId (ou helper
local resolveTenantId/currentTenantId). create_financial_record_for_session ja
passava tenant; retorno SETOF->jsonb transparente (nenhum consumidor indexava
array). Build passa.

list_my_signatures (cross-tenant) -> Lote F.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 15:13:16 -03:00

432 lines
20 KiB
Vue

<!--
|--------------------------------------------------------------------------
| Agência PSI
|--------------------------------------------------------------------------
| Criado e desenvolvido por Leonardo Nohama
|
| Tecnologia aplicada à escuta.
| Estrutura para o cuidado.
|
| Arquivo: src/components/agenda/AgendaEventoFinanceiroPanel.vue
| Data: 2026
| Local: São Carlos/SP Brasil
|--------------------------------------------------------------------------
| © 2026 Todos os direitos reservados
|--------------------------------------------------------------------------
-->
<!--
AgendaEventoFinanceiroPanel
Painel compacto de status financeiro exibido dentro do modal de sessão.
Mostra o financial_record vinculado ao evento e permite registrar pagamento
ou gerar cobrança sem sair do contexto da agenda.
Props:
evento linha de agenda_eventos (deve ter: id, tipo, billed,
billing_contract_id, price, patient_id, inicio_em)
Emits:
cobranca-atualizada após qualquer mutação, para o pai recarregar
-->
<script setup>
import { ref, computed, watch } from 'vue';
import { useToast } from 'primevue/usetoast';
import { useConfirm } from 'primevue/useconfirm';
import { supabase } from '@/lib/supabase/client';
import { tenantDb } from '@/lib/supabase/tenantClient';
import { useTenantStore } from '@/stores/tenantStore';
import { useAgendaFinanceiro } from '@/composables/useAgendaFinanceiro';
import { emitirReciboParaSessao } from '@/services/DocumentGenerate.service';
// ── props / emits ─────────────────────────────────────────────────────────────
const props = defineProps({
evento: {
type: Object,
required: true
}
});
const emit = defineEmits(['cobranca-atualizada']);
// ── external ──────────────────────────────────────────────────────────────────
const toast = useToast();
const confirm = useConfirm();
const tenantStore = useTenantStore();
const { gerarCobrancaManual, loading: finLoading, error: finError } = useAgendaFinanceiro();
// ── estado local ──────────────────────────────────────────────────────────────
const record = ref(null); // financial_record vinculado
const fetching = ref(false);
const generating = ref(false);
const emittingRecibo = ref(false);
// ── opções de método de pagamento ─────────────────────────────────────────────
const PAYMENT_METHODS = [
{ label: 'Pix', value: 'pix' },
{ label: 'Depósito', value: 'deposito' },
{ label: 'Dinheiro', value: 'dinheiro' },
{ label: 'Cartão', value: 'cartao' },
{ label: 'Convênio', value: 'convenio' }
];
function paymentLabel(method) {
return PAYMENT_METHODS.find((o) => o.value === method)?.label ?? method ?? '—';
}
// ── formatação ─────────────────────────────────────────────────────────────────
const _brl = new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' });
function fmtBRL(v) {
return _brl.format(v ?? 0);
}
function fmtDate(iso) {
if (!iso) return '—';
const d = iso.includes('T') ? new Date(iso) : new Date(iso + 'T00:00:00');
return new Intl.DateTimeFormat('pt-BR').format(d);
}
// ── config visual de status ────────────────────────────────────────────────────
const STATUS_CFG = {
pending: { label: 'Pendente', severity: 'warn' },
paid: { label: 'Pago', severity: 'success' },
overdue: { label: 'Vencido', severity: 'danger' },
cancelled: { label: 'Cancelado', severity: 'secondary' }
};
// ── computed: cenário a renderizar ────────────────────────────────────────────
const scenario = computed(() => {
if (props.evento.tipo !== 'sessao') return 'noop'; // bloqueio
if (props.evento.billing_contract_id) return 'contrato'; // pacote
if (fetching.value) return 'carregando';
if (record.value) return 'com-cobranca';
return 'sem-cobranca';
});
const canAct = computed(() => record.value && (record.value.status === 'pending' || record.value.status === 'overdue'));
// Sessão encerrada (não rolou) — bloqueia geração de cobrança nova.
// Multa em cancelado/faltou deve passar pelo AgendaStatusChangeConfirmDialog,
// não por "Gerar cobrança" solto que ignora o motivo.
const isSessaoEncerrada = computed(() => {
const s = String(props.evento?.status || '').toLowerCase();
return s === 'cancelado' || s === 'cancelada' || s === 'faltou';
});
const semCobrancaLabel = computed(() => {
const s = String(props.evento?.status || '').toLowerCase();
if (s === 'cancelado' || s === 'cancelada') return 'Sessão cancelada · sem cobrança ativa';
if (s === 'faltou') return 'Sessão não realizada · sem cobrança ativa';
return 'Sem cobrança gerada';
});
// ── buscar financial_record pelo evento ───────────────────────────────────────
async function fetchRecord() {
if (!props.evento.id) return;
fetching.value = true;
try {
// Ignora records cancelados — permite que o user gere nova cobrança
// após cancelar (caso comum: cancelou sem querer ou quer recobrar).
// Sem esse filtro, o scenario ficava em 'com-cobranca' mostrando
// o cancelado, e o botão "Gerar cobrança" sumia.
const { data, error } = await tenantDb().from('financial_records')
.select('id, amount, discount_amount, final_amount, status, due_date, paid_at, payment_method')
.eq('agenda_evento_id', props.evento.id)
.neq('status', 'cancelled')
.order('created_at', { ascending: false })
.limit(1)
.maybeSingle();
if (error) throw error;
record.value = data ?? null;
} catch (e) {
console.warn('[AgendaEventoFinanceiroPanel] fetchRecord:', e?.message);
record.value = null;
} finally {
fetching.value = false;
}
}
watch(
() => props.evento?.id,
() => {
record.value = null;
fetchRecord();
},
{ immediate: true }
);
// ── gerar cobrança ─────────────────────────────────────────────────────────────
async function onGerarCobranca() {
generating.value = true;
try {
const result = await gerarCobrancaManual(props.evento);
if (!result.ok) throw new Error(result.error);
await fetchRecord();
emit('cobranca-atualizada');
toast.add({ severity: 'success', summary: 'Cobrança gerada', detail: `${fmtBRL(props.evento.price ?? 0)} agendado para recebimento.`, life: 3000 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Não foi possível gerar a cobrança.', life: 4000 });
} finally {
generating.value = false;
}
}
// ── dialog: registrar pagamento ───────────────────────────────────────────────
const payDlgVisible = ref(false);
const payDlgMethod = ref(null);
const payDlgLoading = ref(false);
function openPayDialog() {
payDlgMethod.value = null;
payDlgVisible.value = true;
}
async function confirmPayment() {
if (!payDlgMethod.value || !record.value) return;
payDlgLoading.value = true;
try {
const { data, error } = await supabase.rpc('mark_as_paid', {
p_tenant_id: tenantStore.activeTenantId,
p_financial_record_id: record.value.id,
p_payment_method: payDlgMethod.value
});
if (error) throw error;
payDlgVisible.value = false;
await fetchRecord();
emit('cobranca-atualizada');
toast.add({ severity: 'success', summary: 'Pago!', detail: `Recebimento via ${paymentLabel(payDlgMethod.value)} registrado.`, life: 3000 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Não foi possível registrar pagamento.', life: 4000 });
} finally {
payDlgLoading.value = false;
}
}
// ── cancelar cobrança ─────────────────────────────────────────────────────────
function requestCancel() {
confirm.require({
message: `Cancelar a cobrança de ${fmtBRL(record.value?.final_amount)} desta sessão?`,
header: 'Cancelar cobrança',
icon: 'pi pi-exclamation-triangle',
rejectLabel: 'Não',
acceptLabel: 'Sim, cancelar',
acceptSeverity: 'danger',
accept: async () => {
try {
const { error } = await tenantDb().from('financial_records').update({ status: 'cancelled', updated_at: new Date().toISOString() }).eq('id', record.value.id);
if (error) throw error;
await fetchRecord();
emit('cobranca-atualizada');
toast.add({ severity: 'info', summary: 'Cancelado', detail: 'Cobrança cancelada.', life: 3000 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro', detail: e?.message || 'Falha ao cancelar.', life: 4000 });
}
}
});
}
// ── Emitir recibo PDF da sessão ─────────────────────────────────────────────
// Gera, salva (Storage + documents/document_generated) e baixa um recibo
// pré-preenchido com paciente/sessão/valor/forma de pagamento + registro
// profissional do terapeuta (CRP/CRM/CRFa etc — auto-formatado).
async function onEmitirRecibo() {
if (emittingRecibo.value) return;
emittingRecibo.value = true;
try {
await emitirReciboParaSessao(props.evento.id, {
patientId: props.evento.patient_id || props.evento.paciente_id,
valor: record.value?.final_amount ?? record.value?.amount ?? props.evento.price,
formaPagamento: paymentLabel(record.value?.payment_method)
});
toast.add({ severity: 'success', summary: 'Recibo emitido', detail: 'PDF baixado e salvo nos documentos do paciente.', life: 3000 });
} catch (e) {
toast.add({ severity: 'error', summary: 'Erro ao emitir recibo', detail: e?.message || 'Tente novamente.', life: 4500 });
} finally {
emittingRecibo.value = false;
}
}
</script>
<template>
<div>
<!-- Painel principal noop (bloqueios) não renderiza nada -->
<div v-if="scenario !== 'noop'" class="fin-panel">
<!-- Cabeçalho do painel -->
<div class="fin-panel__header">
<i class="pi pi-wallet" />
<span>Cobrança</span>
<Button v-if="props.evento.billed && !fetching" icon="pi pi-refresh" text size="small" severity="secondary" class="ml-auto h-6 w-6" v-tooltip.top="'Recarregar'" @click="fetchRecord" />
</div>
<!-- Sessão de pacote / contrato -->
<div v-if="scenario === 'contrato'" class="fin-panel__body">
<span class="fin-badge fin-badge--contract">
<i class="pi pi-box text-xs" />
Sessão de pacote
</span>
</div>
<!-- Sem cobrança gerada -->
<div v-else-if="scenario === 'sem-cobranca'" class="fin-panel__body fin-panel__body--empty">
<div class="flex items-center gap-2 text-[var(--text-color-secondary)]">
<i class="pi pi-minus-circle text-sm opacity-50" />
<span class="text-sm">{{ semCobrancaLabel }}</span>
</div>
<!-- Botão "Gerar cobrança" aparece em status ativo (agendado/realizado).
Pra cancelado/faltou: sessão não aconteceu cobrança nova não cabe
aqui. Pra registrar multa, usar o dialog de status change. -->
<Button v-if="!isSessaoEncerrada" label="Gerar cobrança" icon="pi pi-plus" size="small" class="rounded-full mt-2" :loading="generating || finLoading" @click="onGerarCobranca" />
<div v-if="props.evento.price && !isSessaoEncerrada" class="text-xs text-[var(--text-color-secondary)] mt-1">Valor da sessão: {{ fmtBRL(props.evento.price) }}</div>
</div>
<!-- Carregando o financial_record -->
<div v-else-if="scenario === 'carregando'" class="fin-panel__body">
<div class="flex flex-col gap-1.5">
<Skeleton height="1rem" class="w-24" />
<Skeleton height="1.5rem" class="w-32" />
<Skeleton height="1rem" class="w-20" />
</div>
</div>
<!-- Com cobrança -->
<div v-else-if="scenario === 'com-cobranca'" class="fin-panel__body">
<!-- Linha de status + valor -->
<div class="flex items-center justify-between gap-2">
<Tag :value="STATUS_CFG[record.status]?.label ?? record.status" :severity="STATUS_CFG[record.status]?.severity" class="text-xs" />
<span class="font-bold text-sm text-[var(--text-color)]">{{ fmtBRL(record.final_amount) }}</span>
</div>
<!-- Vencimento / data de pagamento -->
<div class="flex items-center gap-1.5 text-xs text-[var(--text-color-secondary)] mt-1.5">
<template v-if="record.status === 'paid'">
<i class="pi pi-check-circle text-emerald-500" />
<span class="text-emerald-600">{{ paymentLabel(record.payment_method) }} · {{ fmtDate(record.paid_at) }}</span>
</template>
<template v-else>
<i class="pi pi-calendar" />
<span :class="record.status === 'overdue' ? 'text-red-500 font-semibold' : ''"> Vence {{ fmtDate(record.due_date) }} </span>
</template>
</div>
<!-- Ações: pendente / vencido -->
<div v-if="canAct" class="flex gap-1.5 mt-3">
<Button label="Receber" icon="pi pi-check" size="small" class="rounded-full flex-1" @click="openPayDialog" />
<Button icon="pi pi-times" size="small" severity="danger" outlined class="rounded-full h-7 w-7" v-tooltip.top="'Cancelar cobrança'" @click="requestCancel" />
</div>
<!-- Ação: pago emitir recibo PDF -->
<div v-else-if="record.status === 'paid'" class="flex gap-1.5 mt-3">
<Button
label="Emitir recibo"
icon="pi pi-file-pdf"
size="small"
outlined
class="rounded-full flex-1"
:loading="emittingRecibo"
v-tooltip.top="'Gera PDF e salva nos documentos do paciente'"
@click="onEmitirRecibo"
/>
</div>
</div>
</div>
<!-- Dialog: Registrar Pagamento -->
<Dialog v-model:visible="payDlgVisible" modal :draggable="false" pt:mask:class="backdrop-blur-xs" header="Registrar pagamento" class="w-[92vw] max-w-sm">
<div class="flex flex-col gap-4 pt-1">
<!-- Valor -->
<div class="flex items-center justify-between px-4 py-2.5 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)]">
<span class="text-sm text-[var(--text-color-secondary)]">Valor a receber</span>
<span class="font-bold text-[var(--text-color)]">{{ fmtBRL(record?.final_amount) }}</span>
</div>
<!-- Método (grid de botões) -->
<div>
<div class="text-sm font-semibold text-[var(--text-color)] mb-2">Método de pagamento</div>
<div class="grid grid-cols-3 gap-2">
<button
v-for="opt in PAYMENT_METHODS"
:key="opt.value"
type="button"
class="flex flex-col items-center gap-1 px-2 py-2 rounded-md border text-xs font-medium transition-all duration-150 cursor-pointer select-none"
:class="
payDlgMethod === opt.value
? 'border-[var(--primary-color,#6366f1)] bg-[var(--primary-color,#6366f1)]/10 text-[var(--primary-color,#6366f1)]'
: 'border-[var(--surface-border)] bg-[var(--surface-ground)] text-[var(--text-color-secondary)] hover:border-[var(--primary-color,#6366f1)]/40'
"
@click="payDlgMethod = opt.value"
>
<i
class="text-base"
:class="{
'pi pi-bolt': opt.value === 'pix',
'pi pi-building': opt.value === 'deposito',
'pi pi-money-bill': opt.value === 'dinheiro',
'pi pi-credit-card': opt.value === 'cartao',
'pi pi-id-card': opt.value === 'convenio'
}"
/>
{{ opt.label }}
</button>
</div>
</div>
</div>
<template #footer>
<Button label="Cancelar" severity="secondary" outlined class="rounded-full" :disabled="payDlgLoading" @click="payDlgVisible = false" />
<Button label="Confirmar" icon="pi pi-check" class="rounded-full" :loading="payDlgLoading" :disabled="!payDlgMethod" @click="confirmPayment" />
</template>
</Dialog>
</div>
</template>
<style scoped>
.fin-panel {
border: 1px solid var(--surface-border, #e2e8f0);
border-radius: 0.5rem;
overflow: hidden;
background: var(--surface-card, #fff);
}
.fin-panel__header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--surface-ground, #f8fafc);
border-bottom: 1px solid var(--surface-border, #e2e8f0);
font-size: 0.8rem;
font-weight: 600;
color: var(--text-color-secondary);
}
.fin-panel__body {
padding: 0.75rem;
}
.fin-panel__body--empty {
display: flex;
flex-direction: column;
}
.fin-badge {
display: inline-flex;
align-items: center;
gap: 0.375rem;
font-size: 0.75rem;
font-weight: 600;
padding: 0.25rem 0.625rem;
border-radius: 9999px;
}
.fin-badge--contract {
background: color-mix(in srgb, var(--p-indigo-500, #6366f1) 10%, transparent);
color: var(--p-indigo-600, #4f46e5);
border: 1px solid color-mix(in srgb, var(--p-indigo-500, #6366f1) 20%, transparent);
}
</style>