feat: #45 meta de poupança configurável + conquistas genéricas
This commit is contained in:
@@ -86,9 +86,13 @@ func main() {
|
|||||||
accountHandler := handler.NewAccountHandler(accountSvc)
|
accountHandler := handler.NewAccountHandler(accountSvc)
|
||||||
creditBillHandler := handler.NewCreditBillHandler(creditBillSvc)
|
creditBillHandler := handler.NewCreditBillHandler(creditBillSvc)
|
||||||
|
|
||||||
|
settingsRepo := repository.NewSettingsRepository(pool)
|
||||||
|
settingsSvc := service.NewSettingsService(settingsRepo)
|
||||||
|
settingsHandler := handler.NewSettingsHandler(settingsSvc)
|
||||||
|
|
||||||
dashboardRepo := repository.NewDashboardRepository(pool)
|
dashboardRepo := repository.NewDashboardRepository(pool)
|
||||||
dashboardSvc := service.NewDashboardService(dashboardRepo, recurringSvc, accountRepo, creditBillSvc, pendingBillSvc)
|
dashboardSvc := service.NewDashboardService(dashboardRepo, recurringSvc, accountRepo, creditBillSvc, pendingBillSvc, settingsSvc)
|
||||||
dashboardHandler := handler.NewDashboardHandler(dashboardSvc)
|
dashboardHandler := handler.NewDashboardHandler(dashboardSvc, gameSvc)
|
||||||
|
|
||||||
r.Get("/health", handler.Health)
|
r.Get("/health", handler.Health)
|
||||||
|
|
||||||
@@ -142,6 +146,9 @@ func main() {
|
|||||||
|
|
||||||
r.Get("/dashboard", dashboardHandler.Get)
|
r.Get("/dashboard", dashboardHandler.Get)
|
||||||
|
|
||||||
|
r.Get("/settings", settingsHandler.Get)
|
||||||
|
r.Put("/settings", settingsHandler.Update)
|
||||||
|
|
||||||
r.Get("/game/summary", gameHandler.Summary)
|
r.Get("/game/summary", gameHandler.Summary)
|
||||||
r.Post("/game/xp", gameHandler.AwardXP)
|
r.Post("/game/xp", gameHandler.AwardXP)
|
||||||
r.Post("/game/quests/{id}/claim", gameHandler.ClaimQuest)
|
r.Post("/game/quests/{id}/claim", gameHandler.ClaimQuest)
|
||||||
|
|||||||
@@ -9,10 +9,11 @@ import (
|
|||||||
|
|
||||||
type DashboardHandler struct {
|
type DashboardHandler struct {
|
||||||
svc *service.DashboardService
|
svc *service.DashboardService
|
||||||
|
gameSvc *service.GameService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDashboardHandler(svc *service.DashboardService) *DashboardHandler {
|
func NewDashboardHandler(svc *service.DashboardService, gameSvc *service.GameService) *DashboardHandler {
|
||||||
return &DashboardHandler{svc: svc}
|
return &DashboardHandler{svc: svc, gameSvc: gameSvc}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *DashboardHandler) Get(w http.ResponseWriter, r *http.Request) {
|
func (h *DashboardHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -26,5 +27,14 @@ func (h *DashboardHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
respondError(w, http.StatusInternalServerError, "failed to load dashboard")
|
respondError(w, http.StatusInternalServerError, "failed to load dashboard")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Notify game engine about savings percentage for the current month only
|
||||||
|
if month == time.Now().Format("2006-01") {
|
||||||
|
h.gameSvc.NotifyAction(r.Context(), "savings_checked", map[string]any{
|
||||||
|
"pct": data.SavingsPct,
|
||||||
|
"goal": data.SavingsGoalPct,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
respondJSON(w, http.StatusOK, data)
|
respondJSON(w, http.StatusOK, data)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"financeiro-carvalho/internal/model"
|
||||||
|
"financeiro-carvalho/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SettingsHandler struct {
|
||||||
|
svc *service.SettingsService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSettingsHandler(svc *service.SettingsService) *SettingsHandler {
|
||||||
|
return &SettingsHandler{svc: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SettingsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||||
|
s, err := h.svc.Get(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to get settings")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusOK, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SettingsHandler) Update(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var in model.UserSettings
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||||
|
respondError(w, http.StatusBadRequest, "invalid JSON")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, err := h.svc.Update(r.Context(), in)
|
||||||
|
if err != nil {
|
||||||
|
if err == service.ErrInvalidSavingsGoal {
|
||||||
|
respondError(w, http.StatusUnprocessableEntity, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondError(w, http.StatusInternalServerError, "failed to update settings")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(w, http.StatusOK, out)
|
||||||
|
}
|
||||||
@@ -56,6 +56,9 @@ var m015 string
|
|||||||
//go:embed sql/016_pending_bill_imports.sql
|
//go:embed sql/016_pending_bill_imports.sql
|
||||||
var m016 string
|
var m016 string
|
||||||
|
|
||||||
|
//go:embed sql/017_user_settings.sql
|
||||||
|
var m017 string
|
||||||
|
|
||||||
func Run(ctx context.Context, pool *pgxpool.Pool) error {
|
func Run(ctx context.Context, pool *pgxpool.Pool) error {
|
||||||
// Bootstrap: ensure schema_migrations table exists before checking versions.
|
// Bootstrap: ensure schema_migrations table exists before checking versions.
|
||||||
if _, err := pool.Exec(ctx, `
|
if _, err := pool.Exec(ctx, `
|
||||||
@@ -67,7 +70,7 @@ func Run(ctx context.Context, pool *pgxpool.Pool) error {
|
|||||||
return fmt.Errorf("bootstrap schema_migrations: %w", err)
|
return fmt.Errorf("bootstrap schema_migrations: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
migrations := []string{m001, m002, m003, m004, m005, m006, m007, m008, m009, m010, m011, m012, m013, m014, m015, m016}
|
migrations := []string{m001, m002, m003, m004, m005, m006, m007, m008, m009, m010, m011, m012, m013, m014, m015, m016, m017}
|
||||||
for i, sql := range migrations {
|
for i, sql := range migrations {
|
||||||
version := i + 1
|
version := i + 1
|
||||||
var applied bool
|
var applied bool
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS user_settings (
|
||||||
|
profile_id INTEGER PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
savings_goal_pct NUMERIC(5,2) NOT NULL DEFAULT 40,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
@@ -28,6 +28,7 @@ type DashboardData struct {
|
|||||||
TotalIncome float64 `json:"total_income"`
|
TotalIncome float64 `json:"total_income"`
|
||||||
TotalExpenses float64 `json:"total_expenses"`
|
TotalExpenses float64 `json:"total_expenses"`
|
||||||
SavingsPct float64 `json:"savings_pct"`
|
SavingsPct float64 `json:"savings_pct"`
|
||||||
|
SavingsGoalPct float64 `json:"savings_goal_pct"`
|
||||||
TotalPatrimony float64 `json:"total_patrimony"`
|
TotalPatrimony float64 `json:"total_patrimony"`
|
||||||
ByCategory []CategoryTotal `json:"by_category"`
|
ByCategory []CategoryTotal `json:"by_category"`
|
||||||
MonthlyEvolution []MonthEvolution `json:"monthly_evolution"`
|
MonthlyEvolution []MonthEvolution `json:"monthly_evolution"`
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
type UserSettings struct {
|
||||||
|
SavingsGoalPct float64 `json:"savings_goal_pct"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package repository
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"financeiro-carvalho/internal/middleware"
|
||||||
|
"financeiro-carvalho/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SettingsRepository interface {
|
||||||
|
Get(ctx context.Context) (*model.UserSettings, error)
|
||||||
|
Upsert(ctx context.Context, s model.UserSettings) (*model.UserSettings, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type settingsRepo struct{ pool *pgxpool.Pool }
|
||||||
|
|
||||||
|
func NewSettingsRepository(pool *pgxpool.Pool) SettingsRepository {
|
||||||
|
return &settingsRepo{pool: pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *settingsRepo) Get(ctx context.Context) (*model.UserSettings, error) {
|
||||||
|
pid := middleware.ProfileIDFromCtx(ctx)
|
||||||
|
var s model.UserSettings
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
SELECT savings_goal_pct FROM user_settings WHERE profile_id = $1
|
||||||
|
`, pid).Scan(&s.SavingsGoalPct)
|
||||||
|
if err != nil {
|
||||||
|
// Return defaults if not yet configured
|
||||||
|
return &model.UserSettings{SavingsGoalPct: 40}, nil
|
||||||
|
}
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *settingsRepo) Upsert(ctx context.Context, s model.UserSettings) (*model.UserSettings, error) {
|
||||||
|
pid := middleware.ProfileIDFromCtx(ctx)
|
||||||
|
var out model.UserSettings
|
||||||
|
err := r.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO user_settings (profile_id, savings_goal_pct)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (profile_id) DO UPDATE
|
||||||
|
SET savings_goal_pct = EXCLUDED.savings_goal_pct, updated_at = NOW()
|
||||||
|
RETURNING savings_goal_pct
|
||||||
|
`, pid, s.SavingsGoalPct).Scan(&out.SavingsGoalPct)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
@@ -24,10 +24,11 @@ type DashboardService struct {
|
|||||||
patrimony PatrimonySource
|
patrimony PatrimonySource
|
||||||
billSvc *CreditBillService
|
billSvc *CreditBillService
|
||||||
pendingBillSvc *PendingBillService
|
pendingBillSvc *PendingBillService
|
||||||
|
settingsSvc *SettingsService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDashboardService(repo DashboardRepo, recurrSvc *RecurringService, patrimony PatrimonySource, billSvc *CreditBillService, pendingBillSvc *PendingBillService) *DashboardService {
|
func NewDashboardService(repo DashboardRepo, recurrSvc *RecurringService, patrimony PatrimonySource, billSvc *CreditBillService, pendingBillSvc *PendingBillService, settingsSvc *SettingsService) *DashboardService {
|
||||||
return &DashboardService{repo: repo, recurrSvc: recurrSvc, patrimony: patrimony, billSvc: billSvc, pendingBillSvc: pendingBillSvc}
|
return &DashboardService{repo: repo, recurrSvc: recurrSvc, patrimony: patrimony, billSvc: billSvc, pendingBillSvc: pendingBillSvc, settingsSvc: settingsSvc}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *DashboardService) Get(ctx context.Context, month string) (*model.DashboardData, error) {
|
func (s *DashboardService) Get(ctx context.Context, month string) (*model.DashboardData, error) {
|
||||||
@@ -115,11 +116,18 @@ func (s *DashboardService) Get(ctx context.Context, month string) (*model.Dashbo
|
|||||||
pendingBillImports = []model.PendingBillImport{}
|
pendingBillImports = []model.PendingBillImport{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
settings, _ := s.settingsSvc.Get(ctx)
|
||||||
|
goalPct := 40.0
|
||||||
|
if settings != nil {
|
||||||
|
goalPct = settings.SavingsGoalPct
|
||||||
|
}
|
||||||
|
|
||||||
return &model.DashboardData{
|
return &model.DashboardData{
|
||||||
Month: month,
|
Month: month,
|
||||||
TotalIncome: income,
|
TotalIncome: income,
|
||||||
TotalExpenses: expenses,
|
TotalExpenses: expenses,
|
||||||
SavingsPct: savingsPct,
|
SavingsPct: savingsPct,
|
||||||
|
SavingsGoalPct: goalPct,
|
||||||
TotalPatrimony: patrimony,
|
TotalPatrimony: patrimony,
|
||||||
ByCategory: byCategory,
|
ByCategory: byCategory,
|
||||||
MonthlyEvolution: evolution,
|
MonthlyEvolution: evolution,
|
||||||
|
|||||||
@@ -61,12 +61,22 @@ func (m *mockPendingBillRepo) GetByID(_ context.Context, _ int) (*model.PendingB
|
|||||||
}
|
}
|
||||||
func (m *mockPendingBillRepo) Delete(_ context.Context, _ int) error { return nil }
|
func (m *mockPendingBillRepo) Delete(_ context.Context, _ int) error { return nil }
|
||||||
|
|
||||||
|
type mockSettingsRepo struct{}
|
||||||
|
|
||||||
|
func (m *mockSettingsRepo) Get(_ context.Context) (*model.UserSettings, error) {
|
||||||
|
return &model.UserSettings{SavingsGoalPct: 40}, nil
|
||||||
|
}
|
||||||
|
func (m *mockSettingsRepo) Upsert(_ context.Context, s model.UserSettings) (*model.UserSettings, error) {
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
func newDashboardSvc(income, expenses float64) *service.DashboardService {
|
func newDashboardSvc(income, expenses float64) *service.DashboardService {
|
||||||
repo := &mockAccountRepo{}
|
repo := &mockAccountRepo{}
|
||||||
recurrSvc := service.NewRecurringService(newMockRecurring(nil), &mockTxRepo{})
|
recurrSvc := service.NewRecurringService(newMockRecurring(nil), &mockTxRepo{})
|
||||||
billSvc := service.NewCreditBillService(&mockCreditBillRepo{}, repo)
|
billSvc := service.NewCreditBillService(&mockCreditBillRepo{}, repo)
|
||||||
pendingBillSvc := service.NewPendingBillService(&mockPendingBillRepo{}, &mockImportTxRepo{})
|
pendingBillSvc := service.NewPendingBillService(&mockPendingBillRepo{}, &mockImportTxRepo{})
|
||||||
return service.NewDashboardService(&mockDashboardRepo{income: income, expenses: expenses}, recurrSvc, &mockPatrimony{}, billSvc, pendingBillSvc)
|
settingsSvc := service.NewSettingsService(&mockSettingsRepo{})
|
||||||
|
return service.NewDashboardService(&mockDashboardRepo{income: income, expenses: expenses}, recurrSvc, &mockPatrimony{}, billSvc, pendingBillSvc, settingsSvc)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestDashboard_SavingsPct_40(t *testing.T) {
|
func TestDashboard_SavingsPct_40(t *testing.T) {
|
||||||
|
|||||||
@@ -165,9 +165,13 @@ func (s *GameService) NotifyAction(ctx context.Context, action string, extra map
|
|||||||
|
|
||||||
case "savings_checked":
|
case "savings_checked":
|
||||||
pct, _ := extra["pct"].(float64)
|
pct, _ := extra["pct"].(float64)
|
||||||
|
goal, _ := extra["goal"].(float64)
|
||||||
|
if goal <= 0 {
|
||||||
|
goal = 40
|
||||||
|
}
|
||||||
_ = s.repo.UpdateQuestPct(ctx, monthlyPeriod, pct)
|
_ = s.repo.UpdateQuestPct(ctx, monthlyPeriod, pct)
|
||||||
if pct >= 40 {
|
if pct >= goal {
|
||||||
_, _ = s.AwardXP(ctx, "savings_goal_met", 200, "Meta de 40% atingida!")
|
_, _ = s.AwardXP(ctx, "savings_goal_met", 200, "Meta de poupança atingida!")
|
||||||
s.tryUnlockAchievement(ctx, "first_40pct")
|
s.tryUnlockAchievement(ctx, "first_40pct")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"financeiro-carvalho/internal/model"
|
||||||
|
"financeiro-carvalho/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrInvalidSavingsGoal = errors.New("savings_goal_pct must be between 1 and 100")
|
||||||
|
|
||||||
|
type SettingsService struct {
|
||||||
|
repo repository.SettingsRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSettingsService(repo repository.SettingsRepository) *SettingsService {
|
||||||
|
return &SettingsService{repo: repo}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingsService) Get(ctx context.Context) (*model.UserSettings, error) {
|
||||||
|
return s.repo.Get(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingsService) Update(ctx context.Context, in model.UserSettings) (*model.UserSettings, error) {
|
||||||
|
if in.SavingsGoalPct < 1 || in.SavingsGoalPct > 100 {
|
||||||
|
return nil, ErrInvalidSavingsGoal
|
||||||
|
}
|
||||||
|
return s.repo.Upsert(ctx, in)
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { api } from '@/services/api'
|
||||||
|
|
||||||
|
export interface UserSettings {
|
||||||
|
savings_goal_pct: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSettingsStore = defineStore('settings', () => {
|
||||||
|
const settings = ref<UserSettings>({ savings_goal_pct: 40 })
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function fetch() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
settings.value = await api.get<UserSettings>('/settings')
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function update(input: UserSettings) {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
settings.value = await api.put<UserSettings>('/settings', input)
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e.message
|
||||||
|
throw e
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { settings, loading, error, fetch, update }
|
||||||
|
})
|
||||||
@@ -54,7 +54,8 @@ const xpPct = computed(() => {
|
|||||||
return Math.round((p.xp / p.xp_to_next) * 100)
|
return Math.round((p.xp / p.xp_to_next) * 100)
|
||||||
})
|
})
|
||||||
const savingsPct = computed(() => dash.data?.savings_pct ?? 0)
|
const savingsPct = computed(() => dash.data?.savings_pct ?? 0)
|
||||||
const savingsOk = computed(() => savingsPct.value >= 40)
|
const savingsGoal = computed(() => dash.data?.savings_goal_pct ?? 40)
|
||||||
|
const savingsOk = computed(() => savingsPct.value >= savingsGoal.value)
|
||||||
|
|
||||||
const today = new Date()
|
const today = new Date()
|
||||||
const isCurrentMonth = computed(() => currentMonth.value === today.toISOString().slice(0, 7))
|
const isCurrentMonth = computed(() => currentMonth.value === today.toISOString().slice(0, 7))
|
||||||
@@ -142,7 +143,7 @@ async function markLate(id: number) {
|
|||||||
</div>
|
</div>
|
||||||
<XPBar :pct="savingsPct" size="lg" :success="savingsOk" :danger="!savingsOk" />
|
<XPBar :pct="savingsPct" size="lg" :success="savingsOk" :danger="!savingsOk" />
|
||||||
<div class="hero-sub fc-mono">
|
<div class="hero-sub fc-mono">
|
||||||
META: 40% ·
|
META: {{ savingsGoal.toFixed(0) }}% ·
|
||||||
<span :class="savingsOk ? 'fc-text-green' : 'fc-text-red'">
|
<span :class="savingsOk ? 'fc-text-green' : 'fc-text-red'">
|
||||||
{{ savingsOk ? 'META BATIDA ✓' : 'ABAIXO DA META' }}
|
{{ savingsOk ? 'META BATIDA ✓' : 'ABAIXO DA META' }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,11 +1,41 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
import NeonPanel from '@/components/NeonPanel.vue'
|
import NeonPanel from '@/components/NeonPanel.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
onMounted(() => settingsStore.fetch())
|
||||||
|
|
||||||
|
const goalInput = ref<number>(40)
|
||||||
|
const goalError = ref<string | null>(null)
|
||||||
|
const goalSuccess = ref(false)
|
||||||
|
const goalLoading = ref(false)
|
||||||
|
|
||||||
|
// sync input after fetch
|
||||||
|
import { watch } from 'vue'
|
||||||
|
watch(() => settingsStore.settings.savings_goal_pct, (v) => { goalInput.value = v }, { immediate: true })
|
||||||
|
|
||||||
|
async function saveGoal() {
|
||||||
|
goalError.value = null
|
||||||
|
goalSuccess.value = false
|
||||||
|
if (goalInput.value < 1 || goalInput.value > 100) {
|
||||||
|
goalError.value = 'Meta deve estar entre 1% e 100%'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
goalLoading.value = true
|
||||||
|
try {
|
||||||
|
await settingsStore.update({ savings_goal_pct: goalInput.value })
|
||||||
|
goalSuccess.value = true
|
||||||
|
} catch (e: any) {
|
||||||
|
goalError.value = e.message ?? 'Erro ao salvar'
|
||||||
|
} finally {
|
||||||
|
goalLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const currentPassword = ref('')
|
const currentPassword = ref('')
|
||||||
const newPassword = ref('')
|
const newPassword = ref('')
|
||||||
@@ -57,6 +87,31 @@ async function logout() {
|
|||||||
<button class="fc-btn fc-btn--danger fc-settings-logout" @click="logout">SAIR</button>
|
<button class="fc-btn fc-btn--danger fc-settings-logout" @click="logout">SAIR</button>
|
||||||
</NeonPanel>
|
</NeonPanel>
|
||||||
|
|
||||||
|
<NeonPanel title="META DE POUPANÇA">
|
||||||
|
<form class="fc-settings-goal" @submit.prevent="saveGoal">
|
||||||
|
<div class="fc-settings-goal__row">
|
||||||
|
<label class="fc-label fc-pixel" style="font-size:9px">META MENSAL (%)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
v-model.number="goalInput"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
step="1"
|
||||||
|
class="fc-input fc-settings-goal__input"
|
||||||
|
:disabled="goalLoading"
|
||||||
|
/>
|
||||||
|
<button type="submit" class="fc-btn fc-btn--primary" :disabled="goalLoading">
|
||||||
|
{{ goalLoading ? '...' : 'SALVAR' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="fc-settings-goal__hint fc-mono">
|
||||||
|
Conquistas e quests usam este valor como meta de poupança mensal.
|
||||||
|
</p>
|
||||||
|
<p v-if="goalError" class="fc-settings-goal__msg fc-settings-goal__msg--err fc-mono">{{ goalError }}</p>
|
||||||
|
<p v-if="goalSuccess" class="fc-settings-goal__msg fc-settings-goal__msg--ok fc-mono">Meta atualizada!</p>
|
||||||
|
</form>
|
||||||
|
</NeonPanel>
|
||||||
|
|
||||||
<NeonPanel title="TROCAR SENHA">
|
<NeonPanel title="TROCAR SENHA">
|
||||||
<form class="fc-settings-pw" @submit.prevent="changePassword">
|
<form class="fc-settings-pw" @submit.prevent="changePassword">
|
||||||
<input
|
<input
|
||||||
@@ -134,4 +189,12 @@ async function logout() {
|
|||||||
}
|
}
|
||||||
.fc-settings-pw__msg--err { color: var(--fc-red); }
|
.fc-settings-pw__msg--err { color: var(--fc-red); }
|
||||||
.fc-settings-pw__msg--ok { color: var(--fc-green); }
|
.fc-settings-pw__msg--ok { color: var(--fc-green); }
|
||||||
|
|
||||||
|
.fc-settings-goal { display: flex; flex-direction: column; gap: var(--fc-space-2); }
|
||||||
|
.fc-settings-goal__row { display: flex; align-items: center; gap: var(--fc-space-3); }
|
||||||
|
.fc-settings-goal__input { width: 80px; }
|
||||||
|
.fc-settings-goal__hint { font-size: 10px; color: var(--fc-text-dim); margin: 0; }
|
||||||
|
.fc-settings-goal__msg { font-size: 11px; margin: 0; }
|
||||||
|
.fc-settings-goal__msg--err { color: var(--fc-red); }
|
||||||
|
.fc-settings-goal__msg--ok { color: var(--fc-green); }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user