417 lines
16 KiB
Vue
417 lines
16 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 { useAgendaFinanceiro } from '@/composables/useAgendaFinanceiro'
|
|
|
|
// ── props / emits ─────────────────────────────────────────────────────────────
|
|
const props = defineProps({
|
|
evento: {
|
|
type: Object,
|
|
required: true,
|
|
},
|
|
})
|
|
|
|
const emit = defineEmits(['cobranca-atualizada'])
|
|
|
|
// ── external ──────────────────────────────────────────────────────────────────
|
|
const toast = useToast()
|
|
const confirm = useConfirm()
|
|
const { gerarCobrancaManual, loading: finLoading, error: finError } = useAgendaFinanceiro()
|
|
|
|
// ── estado local ──────────────────────────────────────────────────────────────
|
|
const record = ref(null) // financial_record vinculado
|
|
const fetching = ref(false)
|
|
const generating = 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')
|
|
)
|
|
|
|
// ── buscar financial_record pelo evento ───────────────────────────────────────
|
|
async function fetchRecord () {
|
|
if (!props.evento.id) return
|
|
|
|
fetching.value = true
|
|
try {
|
|
const { data, error } = await supabase
|
|
.from('financial_records')
|
|
.select('id, amount, discount_amount, final_amount, status, due_date, paid_at, payment_method')
|
|
.eq('agenda_evento_id', props.evento.id)
|
|
.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_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 supabase
|
|
.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 })
|
|
}
|
|
},
|
|
})
|
|
}
|
|
</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">Sem cobrança gerada</span>
|
|
</div>
|
|
<Button
|
|
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" 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>
|
|
|
|
</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>
|