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]>
247 lines
11 KiB
Vue
247 lines
11 KiB
Vue
<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>
|