From fbcb1d76aa81d13bc0cc29ef428ddd6fa39cc4f9 Mon Sep 17 00:00:00 2001 From: Mlcarvalho1 Date: Tue, 26 May 2026 20:32:05 -0300 Subject: [PATCH] =?UTF-8?q?feat(#20):=20dashboard=20financeiro=20mensal=20?= =?UTF-8?q?=E2=80=94=204=20widgets=20+=20endpoint=20de=20agrega=C3=A7?= =?UTF-8?q?=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/api/cmd/server/main.go | 6 + apps/api/internal/handler/dashboard.go | 30 +++ apps/api/internal/model/dashboard.go | 35 +++ apps/api/internal/repository/dashboard.go | 122 ++++++++++ apps/api/internal/service/dashboard.go | 82 +++++++ apps/api/internal/service/dashboard_test.go | 82 +++++++ apps/web/src/stores/dashboard.ts | 57 +++++ apps/web/src/views/HomeView.vue | 241 +++++++++++++++++++- 8 files changed, 651 insertions(+), 4 deletions(-) create mode 100644 apps/api/internal/handler/dashboard.go create mode 100644 apps/api/internal/model/dashboard.go create mode 100644 apps/api/internal/repository/dashboard.go create mode 100644 apps/api/internal/service/dashboard.go create mode 100644 apps/api/internal/service/dashboard_test.go create mode 100644 apps/web/src/stores/dashboard.ts diff --git a/apps/api/cmd/server/main.go b/apps/api/cmd/server/main.go index b819d08..10055ee 100644 --- a/apps/api/cmd/server/main.go +++ b/apps/api/cmd/server/main.go @@ -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 diff --git a/apps/api/internal/handler/dashboard.go b/apps/api/internal/handler/dashboard.go new file mode 100644 index 0000000..745b01d --- /dev/null +++ b/apps/api/internal/handler/dashboard.go @@ -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) +} diff --git a/apps/api/internal/model/dashboard.go b/apps/api/internal/model/dashboard.go new file mode 100644 index 0000000..f556854 --- /dev/null +++ b/apps/api/internal/model/dashboard.go @@ -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"` +} diff --git a/apps/api/internal/repository/dashboard.go b/apps/api/internal/repository/dashboard.go new file mode 100644 index 0000000..3d1abed --- /dev/null +++ b/apps/api/internal/repository/dashboard.go @@ -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() +} diff --git a/apps/api/internal/service/dashboard.go b/apps/api/internal/service/dashboard.go new file mode 100644 index 0000000..2fb274d --- /dev/null +++ b/apps/api/internal/service/dashboard.go @@ -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 +} diff --git a/apps/api/internal/service/dashboard_test.go b/apps/api/internal/service/dashboard_test.go new file mode 100644 index 0000000..0b1ed09 --- /dev/null +++ b/apps/api/internal/service/dashboard_test.go @@ -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") + } +} diff --git a/apps/web/src/stores/dashboard.ts b/apps/web/src/stores/dashboard.ts new file mode 100644 index 0000000..2973afb --- /dev/null +++ b/apps/web/src/stores/dashboard.ts @@ -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(null) + const loading = ref(false) + const error = ref(null) + + async function fetch(month: string) { + loading.value = true + error.value = null + try { + data.value = await api.get(`/dashboard?month=${month}`) + } catch (e: any) { + error.value = e.message + } finally { + loading.value = false + } + } + + return { data, loading, error, fetch } +}) diff --git a/apps/web/src/views/HomeView.vue b/apps/web/src/views/HomeView.vue index a197dad..f3f1192 100644 --- a/apps/web/src/views/HomeView.vue +++ b/apps/web/src/views/HomeView.vue @@ -1,6 +1,239 @@ + + + +