Copyright, Financeiro, Lançamentos, aprimoramentos de ui

This commit is contained in:
Leonardo
2026-03-21 08:05:40 -03:00
parent 29ed349cf2
commit a89d1f5560
268 changed files with 58870 additions and 1752 deletions
@@ -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