Copyright, Financeiro, Lançamentos, aprimoramentos de ui
This commit is contained in:
@@ -0,0 +1,487 @@
|
||||
<!--
|
||||
|--------------------------------------------------------------------------
|
||||
| Agência PSI
|
||||
|--------------------------------------------------------------------------
|
||||
| Criado e desenvolvido por Leonardo Nohama
|
||||
|
|
||||
| Tecnologia aplicada à escuta.
|
||||
| Estrutura para o cuidado.
|
||||
|
|
||||
| Arquivo: src/features/financeiro/pages/FinanceiroDashboardPage.vue
|
||||
| Data: 2026
|
||||
| Local: São Carlos/SP — Brasil
|
||||
|--------------------------------------------------------------------------
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
-->
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { supabase } from '@/lib/supabase/client'
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
const router = useRouter()
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
async function getUid () {
|
||||
const { data } = await supabase.auth.getUser()
|
||||
return data?.user?.id ?? null
|
||||
}
|
||||
|
||||
// ─── meses para o gráfico ────────────────────────────────────────────────────
|
||||
function getLast6Months () {
|
||||
const months = []
|
||||
const now = new Date()
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
const d = new Date(now.getFullYear(), now.getMonth() - i, 1)
|
||||
months.push({ year: d.getFullYear(), month: d.getMonth() + 1, label: d.toLocaleDateString('pt-BR', { month: 'short', year: '2-digit' }) })
|
||||
}
|
||||
return months
|
||||
}
|
||||
|
||||
// ─── estado: cards de resumo ─────────────────────────────────────────────────
|
||||
const summaryLoading = ref(true)
|
||||
const totalRecebido = ref(0)
|
||||
const totalPendente = ref(0)
|
||||
const totalVencido = ref(0)
|
||||
const totalDespesas = ref(0)
|
||||
|
||||
async function loadSummary (uid) {
|
||||
summaryLoading.value = true
|
||||
const now = new Date()
|
||||
const year = now.getFullYear()
|
||||
const month = now.getMonth() + 1
|
||||
|
||||
try {
|
||||
// Receitas e despesas pagas no mês via RPC
|
||||
const { data: rpc } = await supabase.rpc('get_financial_summary', {
|
||||
p_owner_id: uid, p_year: year, p_month: month,
|
||||
})
|
||||
const s = Array.isArray(rpc) ? rpc[0] : rpc
|
||||
totalRecebido.value = Number(s?.total_receitas ?? 0)
|
||||
totalDespesas.value = Number(s?.total_despesas ?? 0)
|
||||
|
||||
// Pending e overdue separados (sem filtro de mês)
|
||||
const { data: pendRows } = await supabase
|
||||
.from('financial_records')
|
||||
.select('status, final_amount')
|
||||
.eq('owner_id', uid)
|
||||
.is('deleted_at', null)
|
||||
.in('status', ['pending', 'overdue'])
|
||||
|
||||
let pen = 0, ove = 0
|
||||
for (const r of pendRows ?? []) {
|
||||
if (r.status === 'pending') pen += Number(r.final_amount ?? 0)
|
||||
else ove += Number(r.final_amount ?? 0)
|
||||
}
|
||||
totalPendente.value = pen
|
||||
totalVencido.value = ove
|
||||
} finally {
|
||||
summaryLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── estado: gráfico 6 meses ─────────────────────────────────────────────────
|
||||
const chartLoading = ref(true)
|
||||
const chartData = ref(null)
|
||||
const chartOptions = ref({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: { position: 'top' },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: ctx => ` ${_brl.format(ctx.parsed.y)}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
ticks: { callback: v => _brl.format(v) },
|
||||
beginAtZero: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
async function loadChart (uid) {
|
||||
chartLoading.value = true
|
||||
const months = getLast6Months()
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
months.map(m =>
|
||||
supabase.rpc('get_financial_summary', { p_owner_id: uid, p_year: m.year, p_month: m.month })
|
||||
)
|
||||
)
|
||||
const receitas = results.map(r => Number((Array.isArray(r.data) ? r.data[0] : r.data)?.total_receitas ?? 0))
|
||||
const despesas = results.map(r => Number((Array.isArray(r.data) ? r.data[0] : r.data)?.total_despesas ?? 0))
|
||||
|
||||
chartData.value = {
|
||||
labels: months.map(m => m.label),
|
||||
datasets: [
|
||||
{ label: 'Receita', data: receitas, backgroundColor: '#22c55e', borderRadius: 4 },
|
||||
{ label: 'Despesa', data: despesas, backgroundColor: '#ef4444', borderRadius: 4 },
|
||||
],
|
||||
}
|
||||
} finally {
|
||||
chartLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── estado: fluxo de caixa ──────────────────────────────────────────────────
|
||||
const cashflowLoading = ref(true)
|
||||
const cashflowRows = ref([])
|
||||
const cashflowError = ref(false)
|
||||
|
||||
async function loadCashflow () {
|
||||
cashflowLoading.value = true
|
||||
cashflowError.value = false
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('v_cashflow_projection')
|
||||
.select('mes_label, receitas_projetadas, despesas_projetadas, saldo_projetado, count_registros')
|
||||
.order('mes', { ascending: true })
|
||||
if (error) throw error
|
||||
cashflowRows.value = data ?? []
|
||||
} catch {
|
||||
cashflowError.value = true
|
||||
} finally {
|
||||
cashflowLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── estado: últimos lançamentos ─────────────────────────────────────────────
|
||||
const recentLoading = ref(true)
|
||||
const recentRecords = ref([])
|
||||
|
||||
const STATUS_CFG = {
|
||||
pending: { label: 'Pendente', severity: 'warn' },
|
||||
paid: { label: 'Pago', severity: 'success' },
|
||||
overdue: { label: 'Vencido', severity: 'danger' },
|
||||
partial: { label: 'Parcial', severity: 'info' },
|
||||
cancelled: { label: 'Cancelado', severity: 'secondary' },
|
||||
refunded: { label: 'Estornado', severity: 'contrast' },
|
||||
}
|
||||
|
||||
async function loadRecent (uid) {
|
||||
recentLoading.value = true
|
||||
try {
|
||||
const { data } = await supabase.rpc('list_financial_records', {
|
||||
p_owner_id: uid,
|
||||
p_limit: 5,
|
||||
p_offset: 0,
|
||||
})
|
||||
recentRecords.value = data ?? []
|
||||
} finally {
|
||||
recentLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── navegação ───────────────────────────────────────────────────────────────
|
||||
function goToLancamentos () {
|
||||
// Tenta navegar pelo nome da rota; fallback para push relativo
|
||||
const route = router.currentRoute.value
|
||||
const base = route.matched[0]?.path ?? ''
|
||||
router.push(base + '/financeiro/lancamentos')
|
||||
}
|
||||
|
||||
// ─── mount ───────────────────────────────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
const uid = await getUid()
|
||||
if (!uid) return
|
||||
await Promise.all([
|
||||
loadSummary(uid),
|
||||
loadChart(uid),
|
||||
loadCashflow(),
|
||||
loadRecent(uid),
|
||||
])
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-[calc(100vh-4.5rem)] bg-[var(--surface-ground,#f5f7fa)]">
|
||||
<div class="px-3 md:px-4 pt-3 pb-4 flex flex-col gap-3.5">
|
||||
|
||||
<!-- ══════════════════════════════════════
|
||||
Hero
|
||||
═══════════════════════════════════════ -->
|
||||
<section class="relative overflow-hidden rounded-md border border-[var(--surface-border,#e2e8f0)] bg-[var(--surface-card,#fff)] p-2.5">
|
||||
|
||||
<!-- Blobs decorativos -->
|
||||
<div class="absolute inset-0 pointer-events-none overflow-hidden" aria-hidden="true">
|
||||
<div class="absolute w-72 h-72 -top-16 -right-12 rounded-full blur-[70px] bg-indigo-500/10" />
|
||||
<div class="absolute w-80 h-80 top-2 -left-20 rounded-full blur-[70px] bg-emerald-400/[0.08]" />
|
||||
</div>
|
||||
|
||||
<!-- Linha 1: icon + título + botão -->
|
||||
<div class="relative z-[1] flex items-center gap-3">
|
||||
<div class="flex items-center gap-2.5 flex-1 min-w-0">
|
||||
<div class="cfg-subheader__icon grid place-items-center w-10 h-10 rounded-md flex-shrink-0" style="background:color-mix(in srgb,#10b981 15%,transparent);color:#059669">
|
||||
<i class="pi pi-wallet text-lg" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-[1.05rem] font-bold tracking-tight text-[var(--text-color)]">Financeiro</div>
|
||||
<div class="text-[0.78rem] text-[var(--text-color-secondary)] mt-0.5">Resumo e visão geral do período</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0">
|
||||
<Button
|
||||
label="Ver lançamentos"
|
||||
icon="pi pi-list"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="rounded-full hidden sm:flex"
|
||||
@click="goToLancamentos"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Linha 2: quick stats -->
|
||||
<div class="relative z-[1] mt-2.5">
|
||||
<template v-if="summaryLoading">
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-2.5">
|
||||
<div v-for="n in 4" :key="n" class="flex flex-col gap-1.5 px-4 py-2.5 rounded-md border border-[var(--surface-border)]">
|
||||
<Skeleton width="3rem" height="20px" />
|
||||
<Skeleton width="4.5rem" height="10px" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="grid grid-cols-2 lg:grid-cols-4 gap-2.5">
|
||||
|
||||
<!-- Recebido -->
|
||||
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border border-emerald-500/25 bg-emerald-500/5">
|
||||
<div class="text-[1.35rem] font-bold leading-none text-emerald-600">{{ fmtBRL(totalRecebido) }}</div>
|
||||
<div class="flex items-center gap-1.5 text-[0.7rem] text-emerald-700/80 font-semibold">
|
||||
<i class="pi pi-check-circle text-xs" />
|
||||
Recebido (mês)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pendente -->
|
||||
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border border-amber-500/25 bg-amber-500/5">
|
||||
<div class="text-[1.35rem] font-bold leading-none text-amber-500">{{ fmtBRL(totalPendente) }}</div>
|
||||
<div class="flex items-center gap-1.5 text-[0.7rem] text-amber-600/80 font-semibold">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-amber-400 animate-pulse flex-shrink-0" />
|
||||
Pendente
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vencido -->
|
||||
<div
|
||||
class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border"
|
||||
:class="totalVencido > 0 ? 'border-red-500/25 bg-red-500/5' : 'border-[var(--surface-border)] bg-[var(--surface-ground)]'"
|
||||
>
|
||||
<div class="text-[1.35rem] font-bold leading-none" :class="totalVencido > 0 ? 'text-red-500' : 'text-[var(--text-color)]'">{{ fmtBRL(totalVencido) }}</div>
|
||||
<div
|
||||
class="flex items-center gap-1.5 text-[0.7rem] font-semibold"
|
||||
:class="totalVencido > 0 ? 'text-red-600/80' : 'text-[var(--text-color-secondary)] opacity-75'"
|
||||
>
|
||||
<i class="pi pi-exclamation-circle text-xs" />
|
||||
Vencido
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Despesas -->
|
||||
<div class="flex flex-col gap-0.5 px-4 py-2.5 rounded-md border border-[var(--surface-border)] bg-[var(--surface-ground)]">
|
||||
<div class="text-[1.35rem] font-bold leading-none text-[var(--text-color)]">{{ fmtBRL(totalDespesas) }}</div>
|
||||
<div class="flex items-center gap-1.5 text-[0.7rem] text-[var(--text-color-secondary)] opacity-75 font-semibold">
|
||||
<i class="pi pi-arrow-down-left text-xs" />
|
||||
Despesas (mês)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════
|
||||
Gráfico Receita × Despesa
|
||||
═══════════════════════════════════════ -->
|
||||
<section class="dash-card rounded-md">
|
||||
<div class="dash-card__head gap-2.5 p-2.5">
|
||||
<i class="pi pi-chart-bar cfg-subheader__icon w-10 h-10 rounded-md flex-shrink-0" />
|
||||
<div>
|
||||
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Receita × Despesa</div>
|
||||
<div class="dash-card__sub">Comparativo dos últimos 6 meses</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-3 pb-3 pt-2">
|
||||
<div v-if="chartLoading" class="h-[240px]">
|
||||
<Skeleton height="100%" />
|
||||
</div>
|
||||
<div v-else-if="chartData" class="h-[240px]">
|
||||
<Chart type="bar" :data="chartData" :options="chartOptions" style="height:100%" />
|
||||
</div>
|
||||
<div v-else class="flex items-center justify-center gap-2 py-10 text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-info-circle" /> Sem dados para o gráfico.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════
|
||||
Projeção de Caixa
|
||||
═══════════════════════════════════════ -->
|
||||
<section class="dash-card rounded-md">
|
||||
<div class="dash-card__head gap-2.5 p-2.5">
|
||||
<i class="pi pi-calendar cfg-subheader__icon w-10 h-10 rounded-md flex-shrink-0" />
|
||||
<div>
|
||||
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Projeção de Caixa</div>
|
||||
<div class="dash-card__sub">Cobranças em aberto — próximos 6 meses</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-3 pb-3 pt-1">
|
||||
|
||||
<div v-if="cashflowLoading" class="flex flex-col gap-2 pt-1">
|
||||
<Skeleton v-for="n in 6" :key="n" height="2.4rem" border-radius="6px" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="cashflowError" class="flex items-center justify-center gap-2 py-8 text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-info-circle" /> Projeção indisponível.
|
||||
</div>
|
||||
|
||||
<div v-else-if="!cashflowRows.length" class="flex items-center justify-center gap-2 py-8 text-[var(--text-color-secondary)]">
|
||||
<i class="pi pi-check-circle" /> Sem cobranças futuras em aberto.
|
||||
</div>
|
||||
|
||||
<div v-else class="flex flex-col gap-1.5 pt-1">
|
||||
<div
|
||||
v-for="row in cashflowRows"
|
||||
:key="row.mes_label"
|
||||
class="flex items-center gap-3 px-3 py-2.5 rounded-md bg-[var(--surface-ground,#f8fafc)] hover:bg-[var(--surface-hover,#f1f5f9)] transition-colors duration-100"
|
||||
>
|
||||
<span class="font-bold text-[0.8rem] uppercase tracking-wide text-[var(--text-color)] min-w-[3.5rem] flex-shrink-0">{{ row.mes_label }}</span>
|
||||
<div class="flex items-center gap-2 flex-1 flex-wrap text-[0.8rem]">
|
||||
<span class="flex items-center gap-1 text-emerald-600 font-semibold">
|
||||
<i class="pi pi-arrow-up-right text-xs" />
|
||||
{{ fmtBRL(row.receitas_projetadas) }}
|
||||
</span>
|
||||
<span class="text-[var(--text-color-secondary)] opacity-30">·</span>
|
||||
<span class="flex items-center gap-1 text-red-500 font-semibold">
|
||||
<i class="pi pi-arrow-down-left text-xs" />
|
||||
{{ fmtBRL(row.despesas_projetadas) }}
|
||||
</span>
|
||||
<span class="text-[var(--text-color-secondary)] opacity-30">·</span>
|
||||
<span class="font-bold" :class="Number(row.saldo_projetado) >= 0 ? 'text-emerald-600' : 'text-red-500'">
|
||||
saldo {{ fmtBRL(row.saldo_projetado) }}
|
||||
</span>
|
||||
</div>
|
||||
<Tag :value="row.count_registros + ' cobranças'" severity="secondary" class="ml-auto text-xs flex-shrink-0" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══════════════════════════════════════
|
||||
Últimos lançamentos
|
||||
═══════════════════════════════════════ -->
|
||||
<section class="dash-card rounded-md shadow-[0_0_0_3px_color-mix(in_srgb,var(--primary-color)_7%,transparent)]">
|
||||
<div class="dash-card__head gap-2.5 p-2.5">
|
||||
<i class="pi pi-list cfg-subheader__icon w-10 h-10 rounded-md flex-shrink-0" />
|
||||
<div class="flex-1">
|
||||
<div class="font-bold tracking-tight text-[var(--text-color-secondary)]">Últimos lançamentos</div>
|
||||
<div class="dash-card__sub">Cobranças e receitas recentes</div>
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center gap-1 bg-transparent border-none cursor-pointer text-xs font-semibold text-[var(--primary-color,#6366f1)] p-0 flex-shrink-0"
|
||||
@click="goToLancamentos"
|
||||
>
|
||||
Ver todos <i class="pi pi-arrow-right text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="recentLoading" class="px-3 pb-3 pt-1 flex flex-col gap-2">
|
||||
<Skeleton v-for="n in 5" :key="n" height="2.5rem" border-radius="6px" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!recentRecords.length"
|
||||
class="flex items-center justify-center gap-2 py-10 text-[var(--text-color-secondary)]"
|
||||
>
|
||||
<i class="pi pi-wallet opacity-40" /> Nenhum lançamento encontrado.
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
v-else
|
||||
:value="recentRecords"
|
||||
size="small"
|
||||
:show-gridlines="false"
|
||||
class="recent-table"
|
||||
>
|
||||
<Column field="due_date" header="Data">
|
||||
<template #body="{ data }">
|
||||
{{ fmtDate(data.paid_at ?? data.due_date) }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column field="description" header="Descrição">
|
||||
<template #body="{ data }">
|
||||
{{ data.description || data.notes || '—' }}
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Tipo">
|
||||
<template #body="{ data }">
|
||||
<Tag
|
||||
:value="data.type === 'receita' ? 'Receita' : 'Despesa'"
|
||||
:severity="data.type === 'receita' ? 'success' : 'danger'"
|
||||
class="text-xs"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Valor">
|
||||
<template #body="{ data }">
|
||||
<span :class="data.type === 'receita' ? 'text-emerald-600 font-semibold' : 'text-red-500 font-semibold'">
|
||||
{{ fmtBRL(data.final_amount ?? data.amount) }}
|
||||
</span>
|
||||
</template>
|
||||
</Column>
|
||||
|
||||
<Column header="Status">
|
||||
<template #body="{ data }">
|
||||
<Tag
|
||||
:value="STATUS_CFG[data.status]?.label ?? data.status"
|
||||
:severity="STATUS_CFG[data.status]?.severity ?? 'secondary'"
|
||||
:class="data.status === 'refunded' ? 'tag-refunded' : ''"
|
||||
class="text-xs"
|
||||
/>
|
||||
</template>
|
||||
</Column>
|
||||
</DataTable>
|
||||
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* ── recent table ───────────────────────────────────────────── */
|
||||
.recent-table :deep(.p-datatable-thead > tr > th) {
|
||||
background: var(--surface-ground);
|
||||
font-size: .75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .04em;
|
||||
padding: .5rem .75rem;
|
||||
}
|
||||
.recent-table :deep(.p-datatable-tbody > tr > td) {
|
||||
padding: .55rem .75rem;
|
||||
font-size: .85rem;
|
||||
}
|
||||
|
||||
/* ── tag: refunded (roxo) ───────────────────────────────────── */
|
||||
:deep(.tag-refunded) { background: #a855f7 !important; color: #fff !important; }
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user