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