d240c6678f
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>
432 lines
20 KiB
Vue
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" só 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>
|