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)
|
||||
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
|
||||
|
||||
@@ -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>
|
||||
<main style="font-family: sans-serif; padding: 2rem; text-align: center">
|
||||
<h1>Financeiro Carvalho</h1>
|
||||
<p>Sistema de controle financeiro pessoal</p>
|
||||
</main>
|
||||
<div class="page">
|
||||
<!-- Month navigator -->
|
||||
<div class="top-bar">
|
||||
<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>
|
||||
|
||||
<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