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:
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user