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)
recurringHandler := handler.NewRecurringHandler(recurringSvc)
dashboardRepo := repository.NewDashboardRepository(pool)
dashboardSvc := service.NewDashboardService(dashboardRepo, recurringSvc)
dashboardHandler := handler.NewDashboardHandler(dashboardSvc)
r.Get("/health", handler.Health)
r.Route("/api", func(r chi.Router) {
@@ -87,6 +91,8 @@ func main() {
r.Get("/recurring/status", recurringHandler.MonthlyStatus)
r.Post("/recurring/{id}/ignore", recurringHandler.Ignore)
r.Delete("/recurring/{id}/ignore", recurringHandler.Unignore)
r.Get("/dashboard", dashboardHandler.Get)
})
// 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")
}
}