feat(#20): dashboard financeiro mensal — 4 widgets + endpoint de agregação

Adiciona GET /api/dashboard?month=YYYY-MM com resumo mensal (% poupado,
gastos por categoria, evolução 6 meses, últimas 10 transações, recorrências
pendentes). HomeView.vue reescrita com 4 widgets e gráficos CSS nativos.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
2026-05-26 20:32:05 -03:00
co-authored by Claude Sonnet 4.6
parent 12e8500827
commit fbcb1d76aa
8 changed files with 651 additions and 4 deletions
+6
View File
@@ -64,6 +64,10 @@ func main() {
recurringSvc := service.NewRecurringService(recurringRepo, manualTxRepo) recurringSvc := service.NewRecurringService(recurringRepo, manualTxRepo)
recurringHandler := handler.NewRecurringHandler(recurringSvc) recurringHandler := handler.NewRecurringHandler(recurringSvc)
dashboardRepo := repository.NewDashboardRepository(pool)
dashboardSvc := service.NewDashboardService(dashboardRepo, recurringSvc)
dashboardHandler := handler.NewDashboardHandler(dashboardSvc)
r.Get("/health", handler.Health) r.Get("/health", handler.Health)
r.Route("/api", func(r chi.Router) { r.Route("/api", func(r chi.Router) {
@@ -87,6 +91,8 @@ func main() {
r.Get("/recurring/status", recurringHandler.MonthlyStatus) r.Get("/recurring/status", recurringHandler.MonthlyStatus)
r.Post("/recurring/{id}/ignore", recurringHandler.Ignore) r.Post("/recurring/{id}/ignore", recurringHandler.Ignore)
r.Delete("/recurring/{id}/ignore", recurringHandler.Unignore) r.Delete("/recurring/{id}/ignore", recurringHandler.Unignore)
r.Get("/dashboard", dashboardHandler.Get)
}) })
// Serve Vue SPA — non-API routes fall through to index.html // Serve Vue SPA — non-API routes fall through to index.html
+30
View File
@@ -0,0 +1,30 @@
package handler
import (
"net/http"
"time"
"financeiro-carvalho/internal/service"
)
type DashboardHandler struct {
svc *service.DashboardService
}
func NewDashboardHandler(svc *service.DashboardService) *DashboardHandler {
return &DashboardHandler{svc: svc}
}
func (h *DashboardHandler) Get(w http.ResponseWriter, r *http.Request) {
month := r.URL.Query().Get("month")
if month == "" {
month = time.Now().Format("2006-01")
}
data, err := h.svc.Get(r.Context(), month)
if err != nil {
respondError(w, http.StatusInternalServerError, "failed to load dashboard")
return
}
respondJSON(w, http.StatusOK, data)
}
+35
View File
@@ -0,0 +1,35 @@
package model
type CategoryTotal struct {
CategoryID *int `json:"category_id"`
CategoryName string `json:"category_name"`
Color string `json:"color"`
Total float64 `json:"total"`
}
type MonthEvolution struct {
Month string `json:"month"`
Income float64 `json:"income"`
Expenses float64 `json:"expenses"`
Saved float64 `json:"saved"`
}
type RecentTransaction struct {
ID int `json:"id"`
Date string `json:"date"`
Description string `json:"description"`
CategoryName string `json:"category_name"`
Amount float64 `json:"amount"`
Type string `json:"type"`
}
type DashboardData struct {
Month string `json:"month"`
TotalIncome float64 `json:"total_income"`
TotalExpenses float64 `json:"total_expenses"`
SavingsPct float64 `json:"savings_pct"`
ByCategory []CategoryTotal `json:"by_category"`
MonthlyEvolution []MonthEvolution `json:"monthly_evolution"`
RecentTransactions []RecentTransaction `json:"recent_transactions"`
PendingRecurring int `json:"pending_recurring"`
}
+122
View File
@@ -0,0 +1,122 @@
package repository
import (
"context"
"github.com/jackc/pgx/v5/pgxpool"
"financeiro-carvalho/internal/model"
)
type DashboardRepository struct {
pool *pgxpool.Pool
}
func NewDashboardRepository(pool *pgxpool.Pool) *DashboardRepository {
return &DashboardRepository{pool: pool}
}
func (r *DashboardRepository) MonthlySummary(ctx context.Context, month string) (income, expenses float64, err error) {
row := r.pool.QueryRow(ctx, `
SELECT
COALESCE(SUM(CASE WHEN type = 'income' THEN amount ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN type = 'expense' THEN amount ELSE 0 END), 0)
FROM transactions
WHERE to_char(date, 'YYYY-MM') = $1
`, month)
err = row.Scan(&income, &expenses)
return
}
func (r *DashboardRepository) ByCategory(ctx context.Context, month string) ([]model.CategoryTotal, error) {
rows, err := r.pool.Query(ctx, `
SELECT
t.category_id,
COALESCE(c.name, 'Sem categoria') AS category_name,
COALESCE(c.color, '#6B7280') AS color,
SUM(t.amount) AS total
FROM transactions t
LEFT JOIN categories c ON c.id = t.category_id
WHERE to_char(t.date, 'YYYY-MM') = $1
AND t.type = 'expense'
GROUP BY t.category_id, c.name, c.color
ORDER BY total DESC
`, month)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.CategoryTotal
for rows.Next() {
var ct model.CategoryTotal
if err := rows.Scan(&ct.CategoryID, &ct.CategoryName, &ct.Color, &ct.Total); err != nil {
return nil, err
}
out = append(out, ct)
}
return out, rows.Err()
}
func (r *DashboardRepository) MonthlyEvolution(ctx context.Context, month string) ([]model.MonthEvolution, error) {
rows, err := r.pool.Query(ctx, `
SELECT
to_char(m.ms, 'YYYY-MM') AS month,
COALESCE(SUM(CASE WHEN t.type = 'income' THEN t.amount ELSE 0 END), 0) AS income,
COALESCE(SUM(CASE WHEN t.type = 'expense' THEN t.amount ELSE 0 END), 0) AS expenses
FROM generate_series(
date_trunc('month', ($1 || '-01')::date) - INTERVAL '5 months',
date_trunc('month', ($1 || '-01')::date),
'1 month'::interval
) AS m(ms)
LEFT JOIN transactions t ON date_trunc('month', t.date) = m.ms
GROUP BY m.ms
ORDER BY m.ms
`, month)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.MonthEvolution
for rows.Next() {
var me model.MonthEvolution
if err := rows.Scan(&me.Month, &me.Income, &me.Expenses); err != nil {
return nil, err
}
me.Saved = me.Income - me.Expenses
out = append(out, me)
}
return out, rows.Err()
}
func (r *DashboardRepository) RecentTransactions(ctx context.Context, month string) ([]model.RecentTransaction, error) {
rows, err := r.pool.Query(ctx, `
SELECT
t.id,
t.date::text,
t.description,
COALESCE(c.name, 'Sem categoria') AS category_name,
t.amount,
t.type
FROM transactions t
LEFT JOIN categories c ON c.id = t.category_id
WHERE to_char(t.date, 'YYYY-MM') = $1
ORDER BY t.date DESC, t.id DESC
LIMIT 10
`, month)
if err != nil {
return nil, err
}
defer rows.Close()
var out []model.RecentTransaction
for rows.Next() {
var rt model.RecentTransaction
if err := rows.Scan(&rt.ID, &rt.Date, &rt.Description, &rt.CategoryName, &rt.Amount, &rt.Type); err != nil {
return nil, err
}
out = append(out, rt)
}
return out, rows.Err()
}
+82
View File
@@ -0,0 +1,82 @@
package service
import (
"context"
"financeiro-carvalho/internal/model"
)
type DashboardRepo interface {
MonthlySummary(ctx context.Context, month string) (income, expenses float64, err error)
ByCategory(ctx context.Context, month string) ([]model.CategoryTotal, error)
MonthlyEvolution(ctx context.Context, month string) ([]model.MonthEvolution, error)
RecentTransactions(ctx context.Context, month string) ([]model.RecentTransaction, error)
}
type DashboardService struct {
repo DashboardRepo
recurrSvc *RecurringService
}
func NewDashboardService(repo DashboardRepo, recurrSvc *RecurringService) *DashboardService {
return &DashboardService{repo: repo, recurrSvc: recurrSvc}
}
func (s *DashboardService) Get(ctx context.Context, month string) (*model.DashboardData, error) {
income, expenses, err := s.repo.MonthlySummary(ctx, month)
if err != nil {
return nil, err
}
byCategory, err := s.repo.ByCategory(ctx, month)
if err != nil {
return nil, err
}
evolution, err := s.repo.MonthlyEvolution(ctx, month)
if err != nil {
return nil, err
}
recent, err := s.repo.RecentTransactions(ctx, month)
if err != nil {
return nil, err
}
var savingsPct float64
if income > 0 {
savingsPct = (income - expenses) / income * 100
}
statuses, err := s.recurrSvc.MonthlyStatus(ctx, month)
if err != nil {
return nil, err
}
pending := 0
for _, s := range statuses {
if !s.Covered {
pending++
}
}
if byCategory == nil {
byCategory = []model.CategoryTotal{}
}
if evolution == nil {
evolution = []model.MonthEvolution{}
}
if recent == nil {
recent = []model.RecentTransaction{}
}
return &model.DashboardData{
Month: month,
TotalIncome: income,
TotalExpenses: expenses,
SavingsPct: savingsPct,
ByCategory: byCategory,
MonthlyEvolution: evolution,
RecentTransactions: recent,
PendingRecurring: pending,
}, nil
}
@@ -0,0 +1,82 @@
package service_test
import (
"context"
"testing"
"financeiro-carvalho/internal/model"
"financeiro-carvalho/internal/service"
)
type mockDashboardRepo struct {
income float64
expenses float64
}
func (m *mockDashboardRepo) MonthlySummary(_ context.Context, _ string) (float64, float64, error) {
return m.income, m.expenses, nil
}
func (m *mockDashboardRepo) ByCategory(_ context.Context, _ string) ([]model.CategoryTotal, error) {
return nil, nil
}
func (m *mockDashboardRepo) MonthlyEvolution(_ context.Context, _ string) ([]model.MonthEvolution, error) {
return nil, nil
}
func (m *mockDashboardRepo) RecentTransactions(_ context.Context, _ string) ([]model.RecentTransaction, error) {
return nil, nil
}
func newDashboardSvc(income, expenses float64) *service.DashboardService {
recurrSvc := service.NewRecurringService(newMockRecurring(nil), &mockTxRepo{})
return service.NewDashboardService(&mockDashboardRepo{income: income, expenses: expenses}, recurrSvc)
}
func TestDashboard_SavingsPct_40(t *testing.T) {
svc := newDashboardSvc(10000, 6000)
data, err := svc.Get(context.Background(), "2024-03")
if err != nil {
t.Fatal(err)
}
if data.SavingsPct != 40.0 {
t.Errorf("expected 40.0, got %.2f", data.SavingsPct)
}
}
func TestDashboard_SavingsPct_NoIncome(t *testing.T) {
svc := newDashboardSvc(0, 500)
data, err := svc.Get(context.Background(), "2024-03")
if err != nil {
t.Fatal(err)
}
if data.SavingsPct != 0 {
t.Errorf("expected 0.0 when no income, got %.2f", data.SavingsPct)
}
}
func TestDashboard_SavingsPct_Above40(t *testing.T) {
svc := newDashboardSvc(10000, 5000)
data, err := svc.Get(context.Background(), "2024-03")
if err != nil {
t.Fatal(err)
}
if data.SavingsPct != 50.0 {
t.Errorf("expected 50.0, got %.2f", data.SavingsPct)
}
}
func TestDashboard_EmptySlices(t *testing.T) {
svc := newDashboardSvc(0, 0)
data, err := svc.Get(context.Background(), "2024-03")
if err != nil {
t.Fatal(err)
}
if data.ByCategory == nil {
t.Error("ByCategory should not be nil")
}
if data.MonthlyEvolution == nil {
t.Error("MonthlyEvolution should not be nil")
}
if data.RecentTransactions == nil {
t.Error("RecentTransactions should not be nil")
}
}
+57
View File
@@ -0,0 +1,57 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { api } from '@/services/api'
export interface CategoryTotal {
category_id: number | null
category_name: string
color: string
total: number
}
export interface MonthEvolution {
month: string
income: number
expenses: number
saved: number
}
export interface RecentTransaction {
id: number
date: string
description: string
category_name: string
amount: number
type: 'income' | 'expense'
}
export interface DashboardData {
month: string
total_income: number
total_expenses: number
savings_pct: number
by_category: CategoryTotal[]
monthly_evolution: MonthEvolution[]
recent_transactions: RecentTransaction[]
pending_recurring: number
}
export const useDashboardStore = defineStore('dashboard', () => {
const data = ref<DashboardData | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
async function fetch(month: string) {
loading.value = true
error.value = null
try {
data.value = await api.get<DashboardData>(`/dashboard?month=${month}`)
} catch (e: any) {
error.value = e.message
} finally {
loading.value = false
}
}
return { data, loading, error, fetch }
})
+237 -4
View File
@@ -1,6 +1,239 @@
<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> <template>
<main style="font-family: sans-serif; padding: 2rem; text-align: center"> <div class="page">
<h1>Financeiro Carvalho</h1> <!-- Month navigator -->
<p>Sistema de controle financeiro pessoal</p> <div class="top-bar">
</main> <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>
<!-- 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> </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(4, 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>