Files
carvalho-finances/apps/web/src/views/HomeView.vue
T
Mlcavalho1andClaude Sonnet 4.6 9a964423fd feat(#21): contas bancárias e patrimônio consolidado
CRUD de contas (corrente/poupança/investimento/cartão) com saldo calculado
automaticamente. account_id nullable em transactions. Widget patrimônio no
dashboard. Tela /contas. Seletor de conta nas transações manuais.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-05-26 21:34:56 -03:00

247 lines
11 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useDashboardStore } from '@/stores/dashboard'
const store = useDashboardStore()
const currentMonth = ref(new Date().toISOString().slice(0, 7))
onMounted(() => store.fetch(currentMonth.value))
function changeMonth(delta: number) {
const [y, m] = currentMonth.value.split('-').map(Number)
const d = new Date(y, m - 1 + delta, 1)
currentMonth.value = d.toISOString().slice(0, 7)
store.fetch(currentMonth.value)
}
function fmt(v: number) {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })
}
function fmtMonth(m: string) {
const [y, mo] = m.split('-').map(Number)
return new Date(y, mo - 1, 1).toLocaleString('pt-BR', { month: 'short', year: 'numeric' })
}
const maxCategoryTotal = computed(() => {
if (!store.data?.by_category.length) return 1
return Math.max(...store.data.by_category.map((c) => c.total))
})
const maxEvolution = computed(() => {
if (!store.data?.monthly_evolution.length) return 1
return Math.max(...store.data.monthly_evolution.flatMap((m) => [m.income, m.expenses]))
})
</script>
<template>
<div class="page">
<!-- Month navigator -->
<div class="top-bar">
<h1>Dashboard</h1>
<div class="month-nav">
<button class="btn" @click="changeMonth(-1)"></button>
<span class="month-label">{{ fmtMonth(currentMonth) }}</span>
<button class="btn" @click="changeMonth(1)"></button>
</div>
</div>
<div v-if="store.loading" class="loading">Carregando</div>
<template v-else-if="store.data">
<!-- Pending recurring alert -->
<div v-if="store.data.pending_recurring > 0" class="alert-banner">
{{ store.data.pending_recurring }} recorrência{{ store.data.pending_recurring > 1 ? 's' : '' }}
sem cobertura neste mês
</div>
<!-- Summary row -->
<div class="summary-grid">
<div class="card">
<div class="card-label">Receitas</div>
<div class="card-value income">{{ fmt(store.data.total_income) }}</div>
</div>
<div class="card">
<div class="card-label">Gastos</div>
<div class="card-value expense">{{ fmt(store.data.total_expenses) }}</div>
</div>
<div class="card">
<div class="card-label">Saldo</div>
<div class="card-value" :class="store.data.total_income - store.data.total_expenses >= 0 ? 'income' : 'expense'">
{{ fmt(store.data.total_income - store.data.total_expenses) }}
</div>
</div>
<div class="card savings-card" :class="store.data.savings_pct >= 40 ? 'savings-ok' : 'savings-low'">
<div class="card-label">% Poupado</div>
<div class="savings-pct">{{ store.data.savings_pct.toFixed(1) }}%</div>
<div class="savings-target">Meta: 40%</div>
</div>
<div class="card patrimony-card" v-if="store.data.total_patrimony > 0 || store.data.total_patrimony < 0">
<div class="card-label">Patrimônio</div>
<div class="card-value" :class="store.data.total_patrimony >= 0 ? 'income' : 'expense'">
{{ fmt(store.data.total_patrimony) }}
</div>
<div class="savings-target">todas as contas</div>
</div>
</div>
<!-- Bottom section: two columns on wide, stacked on mobile -->
<div class="bottom-grid">
<!-- Category breakdown -->
<div class="card full-card">
<h2>Gastos por categoria</h2>
<div v-if="store.data.by_category.length === 0" class="empty">Nenhum gasto registrado</div>
<div v-else class="cat-list">
<div v-for="cat in store.data.by_category" :key="cat.category_name" class="cat-row">
<div class="cat-name">{{ cat.category_name }}</div>
<div class="cat-bar-wrap">
<div
class="cat-bar"
:style="{ width: (cat.total / maxCategoryTotal * 100).toFixed(1) + '%', background: cat.color }"
></div>
</div>
<div class="cat-amount">{{ fmt(cat.total) }}</div>
</div>
</div>
</div>
<!-- Monthly evolution -->
<div class="card full-card">
<h2>Evolução mensal</h2>
<div v-if="store.data.monthly_evolution.length === 0" class="empty">Sem dados</div>
<div v-else class="evo-chart">
<div v-for="me in store.data.monthly_evolution" :key="me.month" class="evo-col">
<div class="evo-bars">
<div
class="evo-bar income-bar"
:style="{ height: maxEvolution > 0 ? (me.income / maxEvolution * 100).toFixed(1) + '%' : '0%' }"
:title="'Receita: ' + fmt(me.income)"
></div>
<div
class="evo-bar expense-bar"
:style="{ height: maxEvolution > 0 ? (me.expenses / maxEvolution * 100).toFixed(1) + '%' : '0%' }"
:title="'Gasto: ' + fmt(me.expenses)"
></div>
</div>
<div class="evo-label">{{ me.month.slice(5) }}/{{ me.month.slice(2, 4) }}</div>
</div>
</div>
<div class="evo-legend">
<span class="legend-dot income-dot"></span> Receita
<span class="legend-dot expense-dot"></span> Gasto
</div>
</div>
</div>
<!-- Recent transactions -->
<div class="card full-card mt">
<h2>Transações recentes</h2>
<div v-if="store.data.recent_transactions.length === 0" class="empty">Nenhuma transação</div>
<div v-else class="recent-list">
<div
v-for="t in store.data.recent_transactions"
:key="t.id"
class="recent-row"
>
<span class="recent-date">{{ t.date }}</span>
<span class="recent-desc">{{ t.description }}</span>
<span class="recent-cat">{{ t.category_name }}</span>
<span class="recent-amt" :class="t.type === 'income' ? 'income' : 'expense'">
{{ t.type === 'income' ? '+' : '-' }}{{ fmt(t.amount) }}
</span>
</div>
</div>
</div>
</template>
<div v-else-if="store.error" class="error">Erro ao carregar: {{ store.error }}</div>
</div>
</template>
<style scoped>
.page { max-width: 960px; margin: 0 auto; padding: 1.5rem 1rem; font-family: sans-serif; }
.top-bar { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1.25rem; flex-wrap: wrap; gap: 0.75rem; }
h1 { font-size: 1.5rem; margin: 0; }
h2 { font-size: 0.95rem; font-weight: 600; margin: 0 0 0.75rem; color: #374151; }
.month-nav { display: flex; align-items: center; gap: 0.5rem; }
.month-label { font-size: 0.9rem; font-weight: 600; min-width: 7rem; text-align: center; }
.btn { padding: 0.35rem 0.75rem; border: 1px solid #d1d5db; border-radius: 6px; cursor: pointer; background: #fff; font-size: 1rem; line-height: 1; }
.loading { text-align: center; padding: 3rem; color: #9ca3af; }
.error { color: #dc2626; padding: 2rem; text-align: center; }
.alert-banner {
background: #fef3c7; border: 1px solid #fcd34d; border-radius: 8px;
padding: 0.6rem 1rem; margin-bottom: 1rem; font-size: 0.875rem; color: #92400e;
}
/* Summary */
.summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 0.75rem; margin-bottom: 1rem; }
@media (max-width: 600px) { .summary-grid { grid-template-columns: 1fr 1fr; } }
.card { background: #fff; border: 1px solid #e5e7eb; border-radius: 10px; padding: 1rem; }
.card-label { font-size: 0.75rem; color: #6b7280; text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 0.35rem; }
.card-value { font-size: 1.15rem; font-weight: 700; }
.income { color: #059669; }
.expense { color: #dc2626; }
.savings-card { border-width: 2px; }
.savings-ok { border-color: #34d399; background: #f0fdf4; }
.savings-low { border-color: #f87171; background: #fef2f2; }
.savings-pct { font-size: 2rem; font-weight: 800; line-height: 1; }
.savings-ok .savings-pct { color: #059669; }
.savings-low .savings-pct { color: #dc2626; }
.savings-target { font-size: 0.7rem; color: #9ca3af; margin-top: 0.2rem; }
/* Bottom grid */
.bottom-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-bottom: 0.75rem; }
@media (max-width: 640px) { .bottom-grid { grid-template-columns: 1fr; } }
.full-card { padding: 1rem; }
.mt { margin-top: 0; }
.empty { color: #9ca3af; font-size: 0.875rem; padding: 1rem 0; }
/* Category bars */
.cat-list { display: flex; flex-direction: column; gap: 0.5rem; }
.cat-row { display: grid; grid-template-columns: 130px 1fr 90px; align-items: center; gap: 0.5rem; }
@media (max-width: 480px) { .cat-row { grid-template-columns: 1fr; gap: 0.2rem; } }
.cat-name { font-size: 0.8rem; color: #374151; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cat-bar-wrap { background: #f3f4f6; border-radius: 4px; height: 12px; overflow: hidden; }
.cat-bar { height: 100%; border-radius: 4px; min-width: 4px; transition: width 0.3s; }
.cat-amount { font-size: 0.8rem; font-weight: 600; color: #374151; text-align: right; }
/* Evolution chart */
.evo-chart { display: flex; align-items: flex-end; gap: 0.5rem; height: 120px; padding-bottom: 1.5rem; position: relative; }
.evo-col { flex: 1; display: flex; flex-direction: column; align-items: center; height: 100%; }
.evo-bars { display: flex; align-items: flex-end; gap: 2px; height: 100%; width: 100%; }
.evo-bar { flex: 1; border-radius: 3px 3px 0 0; min-height: 2px; transition: height 0.3s; }
.income-bar { background: #34d399; }
.expense-bar { background: #f87171; }
.evo-label { font-size: 0.65rem; color: #9ca3af; margin-top: 4px; white-space: nowrap; }
.evo-legend { display: flex; align-items: center; gap: 1rem; font-size: 0.75rem; color: #6b7280; margin-top: 0.25rem; }
.legend-dot { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 3px; }
.income-dot { background: #34d399; }
.expense-dot { background: #f87171; }
/* Recent transactions */
.recent-list { display: flex; flex-direction: column; gap: 0; }
.recent-row {
display: grid;
grid-template-columns: 90px 1fr 120px 120px;
align-items: center;
padding: 0.45rem 0;
border-bottom: 1px solid #f3f4f6;
font-size: 0.85rem;
}
@media (max-width: 500px) {
.recent-row { grid-template-columns: 80px 1fr 90px; }
.recent-cat { display: none; }
}
.recent-date { color: #9ca3af; font-size: 0.8rem; }
.recent-desc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.recent-cat { color: #6b7280; font-size: 0.8rem; }
.recent-amt { text-align: right; font-weight: 600; }
</style>