feat: allow multi tags for budgets
This commit is contained in:
parent
10e084773c
commit
55e04c9b94
7 changed files with 268 additions and 80 deletions
38
internal/db/migrations/00013_budget_tags_table.sql
Normal file
38
internal/db/migrations/00013_budget_tags_table.sql
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
-- +goose Up
|
||||||
|
-- Create budget_tags join table for many-to-many relationship
|
||||||
|
CREATE TABLE budget_tags (
|
||||||
|
budget_id TEXT NOT NULL,
|
||||||
|
tag_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (budget_id, tag_id),
|
||||||
|
FOREIGN KEY (budget_id) REFERENCES budgets(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_budget_tags_tag_id ON budget_tags(tag_id);
|
||||||
|
|
||||||
|
-- Migrate existing tag_id data to the join table
|
||||||
|
INSERT INTO budget_tags (budget_id, tag_id)
|
||||||
|
SELECT id, tag_id FROM budgets WHERE tag_id IS NOT NULL;
|
||||||
|
|
||||||
|
-- Drop the unique constraint and FK that reference tag_id, then drop the column
|
||||||
|
ALTER TABLE budgets DROP CONSTRAINT budgets_space_id_tag_id_period_key;
|
||||||
|
ALTER TABLE budgets DROP CONSTRAINT budgets_tag_id_fkey;
|
||||||
|
ALTER TABLE budgets DROP COLUMN tag_id;
|
||||||
|
|
||||||
|
-- +goose Down
|
||||||
|
-- Add tag_id column back
|
||||||
|
ALTER TABLE budgets ADD COLUMN tag_id TEXT;
|
||||||
|
|
||||||
|
-- Copy first tag back from budget_tags
|
||||||
|
UPDATE budgets SET tag_id = (
|
||||||
|
SELECT tag_id FROM budget_tags WHERE budget_tags.budget_id = budgets.id LIMIT 1
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Re-add FK and unique constraint
|
||||||
|
ALTER TABLE budgets ADD CONSTRAINT budgets_tag_id_fkey
|
||||||
|
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE;
|
||||||
|
ALTER TABLE budgets ADD CONSTRAINT budgets_space_id_tag_id_period_key
|
||||||
|
UNIQUE (space_id, tag_id, period);
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS idx_budget_tags_tag_id;
|
||||||
|
DROP TABLE IF EXISTS budget_tags;
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
|
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
|
||||||
|
|
@ -125,17 +126,10 @@ func (h *SpaceHandler) OverviewPage(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Budgets
|
// Budgets
|
||||||
tags, err := h.tagService.GetTagsForSpace(spaceID)
|
budgets, err := h.budgetService.GetBudgetsWithSpent(spaceID)
|
||||||
if err != nil {
|
|
||||||
slog.Error("failed to get tags", "error", err, "space_id", spaceID)
|
|
||||||
}
|
|
||||||
var budgets []*model.BudgetWithSpent
|
|
||||||
if tags != nil {
|
|
||||||
budgets, err = h.budgetService.GetBudgetsWithSpent(spaceID, tags)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to get budgets", "error", err, "space_id", spaceID)
|
slog.Error("failed to get budgets", "error", err, "space_id", spaceID)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Recurring expenses
|
// Recurring expenses
|
||||||
recs, err := h.recurringService.GetRecurringExpensesWithTagsAndMethodsForSpace(spaceID)
|
recs, err := h.recurringService.GetRecurringExpensesWithTagsAndMethodsForSpace(spaceID)
|
||||||
|
|
@ -1991,7 +1985,7 @@ func (h *SpaceHandler) BudgetsPage(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
budgets, err := h.budgetService.GetBudgetsWithSpent(spaceID, tags)
|
budgets, err := h.budgetService.GetBudgetsWithSpent(spaceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to get budgets", "error", err, "space_id", spaceID)
|
slog.Error("failed to get budgets", "error", err, "space_id", spaceID)
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
|
@ -2010,13 +2004,23 @@ func (h *SpaceHandler) CreateBudget(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tagID := r.FormValue("tag_id")
|
tagIDsStr := r.FormValue("tag_ids")
|
||||||
amountStr := r.FormValue("amount")
|
amountStr := r.FormValue("amount")
|
||||||
periodStr := r.FormValue("period")
|
periodStr := r.FormValue("period")
|
||||||
startDateStr := r.FormValue("start_date")
|
startDateStr := r.FormValue("start_date")
|
||||||
endDateStr := r.FormValue("end_date")
|
endDateStr := r.FormValue("end_date")
|
||||||
|
|
||||||
if tagID == "" || amountStr == "" || periodStr == "" || startDateStr == "" {
|
var tagIDs []string
|
||||||
|
if tagIDsStr != "" {
|
||||||
|
for _, id := range strings.Split(tagIDsStr, ",") {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id != "" {
|
||||||
|
tagIDs = append(tagIDs, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tagIDs) == 0 || amountStr == "" || periodStr == "" || startDateStr == "" {
|
||||||
ui.RenderError(w, r, "All required fields must be provided.", http.StatusUnprocessableEntity)
|
ui.RenderError(w, r, "All required fields must be provided.", http.StatusUnprocessableEntity)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -2046,7 +2050,7 @@ func (h *SpaceHandler) CreateBudget(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
_, err = h.budgetService.CreateBudget(service.CreateBudgetDTO{
|
_, err = h.budgetService.CreateBudget(service.CreateBudgetDTO{
|
||||||
SpaceID: spaceID,
|
SpaceID: spaceID,
|
||||||
TagID: tagID,
|
TagIDs: tagIDs,
|
||||||
Amount: amountCents,
|
Amount: amountCents,
|
||||||
Period: model.BudgetPeriod(periodStr),
|
Period: model.BudgetPeriod(periodStr),
|
||||||
StartDate: startDate,
|
StartDate: startDate,
|
||||||
|
|
@ -2061,7 +2065,7 @@ func (h *SpaceHandler) CreateBudget(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
// Refresh the full budgets list
|
// Refresh the full budgets list
|
||||||
tags, _ := h.tagService.GetTagsForSpace(spaceID)
|
tags, _ := h.tagService.GetTagsForSpace(spaceID)
|
||||||
budgets, _ := h.budgetService.GetBudgetsWithSpent(spaceID, tags)
|
budgets, _ := h.budgetService.GetBudgetsWithSpent(spaceID)
|
||||||
ui.Render(w, r, pages.BudgetsList(spaceID, budgets, tags))
|
ui.Render(w, r, pages.BudgetsList(spaceID, budgets, tags))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2078,13 +2082,23 @@ func (h *SpaceHandler) UpdateBudget(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
tagID := r.FormValue("tag_id")
|
tagIDsStr := r.FormValue("tag_ids")
|
||||||
amountStr := r.FormValue("amount")
|
amountStr := r.FormValue("amount")
|
||||||
periodStr := r.FormValue("period")
|
periodStr := r.FormValue("period")
|
||||||
startDateStr := r.FormValue("start_date")
|
startDateStr := r.FormValue("start_date")
|
||||||
endDateStr := r.FormValue("end_date")
|
endDateStr := r.FormValue("end_date")
|
||||||
|
|
||||||
if tagID == "" || amountStr == "" || periodStr == "" || startDateStr == "" {
|
var tagIDs []string
|
||||||
|
if tagIDsStr != "" {
|
||||||
|
for _, id := range strings.Split(tagIDsStr, ",") {
|
||||||
|
id = strings.TrimSpace(id)
|
||||||
|
if id != "" {
|
||||||
|
tagIDs = append(tagIDs, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tagIDs) == 0 || amountStr == "" || periodStr == "" || startDateStr == "" {
|
||||||
ui.RenderError(w, r, "All required fields must be provided.", http.StatusUnprocessableEntity)
|
ui.RenderError(w, r, "All required fields must be provided.", http.StatusUnprocessableEntity)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -2114,7 +2128,7 @@ func (h *SpaceHandler) UpdateBudget(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
_, err = h.budgetService.UpdateBudget(service.UpdateBudgetDTO{
|
_, err = h.budgetService.UpdateBudget(service.UpdateBudgetDTO{
|
||||||
ID: budgetID,
|
ID: budgetID,
|
||||||
TagID: tagID,
|
TagIDs: tagIDs,
|
||||||
Amount: amountCents,
|
Amount: amountCents,
|
||||||
Period: model.BudgetPeriod(periodStr),
|
Period: model.BudgetPeriod(periodStr),
|
||||||
StartDate: startDate,
|
StartDate: startDate,
|
||||||
|
|
@ -2128,7 +2142,7 @@ func (h *SpaceHandler) UpdateBudget(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
||||||
// Refresh the full budgets list
|
// Refresh the full budgets list
|
||||||
tags, _ := h.tagService.GetTagsForSpace(spaceID)
|
tags, _ := h.tagService.GetTagsForSpace(spaceID)
|
||||||
budgets, _ := h.budgetService.GetBudgetsWithSpent(spaceID, tags)
|
budgets, _ := h.budgetService.GetBudgetsWithSpent(spaceID)
|
||||||
ui.Render(w, r, pages.BudgetsList(spaceID, budgets, tags))
|
ui.Render(w, r, pages.BudgetsList(spaceID, budgets, tags))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2160,7 +2174,7 @@ func (h *SpaceHandler) GetBudgetsList(w http.ResponseWriter, r *http.Request) {
|
||||||
spaceID := r.PathValue("spaceID")
|
spaceID := r.PathValue("spaceID")
|
||||||
|
|
||||||
tags, _ := h.tagService.GetTagsForSpace(spaceID)
|
tags, _ := h.tagService.GetTagsForSpace(spaceID)
|
||||||
budgets, err := h.budgetService.GetBudgetsWithSpent(spaceID, tags)
|
budgets, err := h.budgetService.GetBudgetsWithSpent(spaceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to get budgets", "error", err, "space_id", spaceID)
|
slog.Error("failed to get budgets", "error", err, "space_id", spaceID)
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
package model
|
package model
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
type BudgetPeriod string
|
type BudgetPeriod string
|
||||||
|
|
||||||
|
|
@ -21,7 +24,6 @@ const (
|
||||||
type Budget struct {
|
type Budget struct {
|
||||||
ID string `db:"id"`
|
ID string `db:"id"`
|
||||||
SpaceID string `db:"space_id"`
|
SpaceID string `db:"space_id"`
|
||||||
TagID string `db:"tag_id"`
|
|
||||||
AmountCents int `db:"amount_cents"`
|
AmountCents int `db:"amount_cents"`
|
||||||
Period BudgetPeriod `db:"period"`
|
Period BudgetPeriod `db:"period"`
|
||||||
StartDate time.Time `db:"start_date"`
|
StartDate time.Time `db:"start_date"`
|
||||||
|
|
@ -34,9 +36,16 @@ type Budget struct {
|
||||||
|
|
||||||
type BudgetWithSpent struct {
|
type BudgetWithSpent struct {
|
||||||
Budget
|
Budget
|
||||||
TagName string `db:"tag_name"`
|
Tags []*Tag
|
||||||
TagColor *string `db:"tag_color"`
|
|
||||||
SpentCents int
|
SpentCents int
|
||||||
Percentage float64
|
Percentage float64
|
||||||
Status BudgetStatus
|
Status BudgetStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (b *BudgetWithSpent) TagNames() string {
|
||||||
|
names := make([]string, len(b.Tags))
|
||||||
|
for i, t := range b.Tags {
|
||||||
|
names[i] = t.Name
|
||||||
|
}
|
||||||
|
return strings.Join(names, ", ")
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,11 +14,12 @@ var (
|
||||||
)
|
)
|
||||||
|
|
||||||
type BudgetRepository interface {
|
type BudgetRepository interface {
|
||||||
Create(budget *model.Budget) error
|
Create(budget *model.Budget, tagIDs []string) error
|
||||||
GetByID(id string) (*model.Budget, error)
|
GetByID(id string) (*model.Budget, error)
|
||||||
GetBySpaceID(spaceID string) ([]*model.Budget, error)
|
GetBySpaceID(spaceID string) ([]*model.Budget, error)
|
||||||
GetSpentForBudget(spaceID, tagID string, periodStart, periodEnd time.Time) (int, error)
|
GetSpentForBudget(spaceID string, tagIDs []string, periodStart, periodEnd time.Time) (int, error)
|
||||||
Update(budget *model.Budget) error
|
GetTagsByBudgetIDs(budgetIDs []string) (map[string][]*model.Tag, error)
|
||||||
|
Update(budget *model.Budget, tagIDs []string) error
|
||||||
Delete(id string) error
|
Delete(id string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -30,11 +31,30 @@ func NewBudgetRepository(db *sqlx.DB) BudgetRepository {
|
||||||
return &budgetRepository{db: db}
|
return &budgetRepository{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *budgetRepository) Create(budget *model.Budget) error {
|
func (r *budgetRepository) Create(budget *model.Budget, tagIDs []string) error {
|
||||||
query := `INSERT INTO budgets (id, space_id, tag_id, amount_cents, period, start_date, end_date, is_active, created_by, created_at, updated_at)
|
tx, err := r.db.Beginx()
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11);`
|
if err != nil {
|
||||||
_, err := r.db.Exec(query, budget.ID, budget.SpaceID, budget.TagID, budget.AmountCents, budget.Period, budget.StartDate, budget.EndDate, budget.IsActive, budget.CreatedBy, budget.CreatedAt, budget.UpdatedAt)
|
|
||||||
return err
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
query := `INSERT INTO budgets (id, space_id, amount_cents, period, start_date, end_date, is_active, created_by, created_at, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10);`
|
||||||
|
_, err = tx.Exec(query, budget.ID, budget.SpaceID, budget.AmountCents, budget.Period, budget.StartDate, budget.EndDate, budget.IsActive, budget.CreatedBy, budget.CreatedAt, budget.UpdatedAt)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tagIDs) > 0 {
|
||||||
|
tagQuery := `INSERT INTO budget_tags (budget_id, tag_id) VALUES ($1, $2);`
|
||||||
|
for _, tagID := range tagIDs {
|
||||||
|
if _, err := tx.Exec(tagQuery, budget.ID, tagID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *budgetRepository) GetByID(id string) (*model.Budget, error) {
|
func (r *budgetRepository) GetByID(id string) (*model.Budget, error) {
|
||||||
|
|
@ -52,23 +72,97 @@ func (r *budgetRepository) GetBySpaceID(spaceID string) ([]*model.Budget, error)
|
||||||
return budgets, err
|
return budgets, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *budgetRepository) GetSpentForBudget(spaceID, tagID string, periodStart, periodEnd time.Time) (int, error) {
|
func (r *budgetRepository) GetSpentForBudget(spaceID string, tagIDs []string, periodStart, periodEnd time.Time) (int, error) {
|
||||||
var spent int
|
if len(tagIDs) == 0 {
|
||||||
query := `
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
query, args, err := sqlx.In(`
|
||||||
SELECT COALESCE(SUM(e.amount_cents), 0)
|
SELECT COALESCE(SUM(e.amount_cents), 0)
|
||||||
FROM expenses e
|
FROM expenses e
|
||||||
JOIN expense_tags et ON e.id = et.expense_id
|
WHERE e.space_id = ? AND e.type = 'expense' AND e.date >= ? AND e.date <= ?
|
||||||
WHERE e.space_id = $1 AND et.tag_id = $2 AND e.type = 'expense'
|
AND EXISTS (SELECT 1 FROM expense_tags et WHERE et.expense_id = e.id AND et.tag_id IN (?))
|
||||||
AND e.date >= $3 AND e.date <= $4;
|
`, spaceID, periodStart, periodEnd, tagIDs)
|
||||||
`
|
if err != nil {
|
||||||
err := r.db.Get(&spent, query, spaceID, tagID, periodStart, periodEnd)
|
return 0, err
|
||||||
|
}
|
||||||
|
query = r.db.Rebind(query)
|
||||||
|
|
||||||
|
var spent int
|
||||||
|
err = r.db.Get(&spent, query, args...)
|
||||||
return spent, err
|
return spent, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *budgetRepository) Update(budget *model.Budget) error {
|
func (r *budgetRepository) GetTagsByBudgetIDs(budgetIDs []string) (map[string][]*model.Tag, error) {
|
||||||
query := `UPDATE budgets SET tag_id = $1, amount_cents = $2, period = $3, start_date = $4, end_date = $5, is_active = $6, updated_at = $7 WHERE id = $8;`
|
if len(budgetIDs) == 0 {
|
||||||
_, err := r.db.Exec(query, budget.TagID, budget.AmountCents, budget.Period, budget.StartDate, budget.EndDate, budget.IsActive, budget.UpdatedAt, budget.ID)
|
return make(map[string][]*model.Tag), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type row struct {
|
||||||
|
BudgetID string `db:"budget_id"`
|
||||||
|
ID string `db:"id"`
|
||||||
|
SpaceID string `db:"space_id"`
|
||||||
|
Name string `db:"name"`
|
||||||
|
Color *string `db:"color"`
|
||||||
|
}
|
||||||
|
|
||||||
|
query, args, err := sqlx.In(`
|
||||||
|
SELECT bt.budget_id, t.id, t.space_id, t.name, t.color
|
||||||
|
FROM budget_tags bt
|
||||||
|
JOIN tags t ON bt.tag_id = t.id
|
||||||
|
WHERE bt.budget_id IN (?)
|
||||||
|
ORDER BY t.name;
|
||||||
|
`, budgetIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
query = r.db.Rebind(query)
|
||||||
|
|
||||||
|
var rows []row
|
||||||
|
if err := r.db.Select(&rows, query, args...); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[string][]*model.Tag)
|
||||||
|
for _, rw := range rows {
|
||||||
|
result[rw.BudgetID] = append(result[rw.BudgetID], &model.Tag{
|
||||||
|
ID: rw.ID,
|
||||||
|
SpaceID: rw.SpaceID,
|
||||||
|
Name: rw.Name,
|
||||||
|
Color: rw.Color,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *budgetRepository) Update(budget *model.Budget, tagIDs []string) error {
|
||||||
|
tx, err := r.db.Beginx()
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
query := `UPDATE budgets SET amount_cents = $1, period = $2, start_date = $3, end_date = $4, is_active = $5, updated_at = $6 WHERE id = $7;`
|
||||||
|
_, err = tx.Exec(query, budget.AmountCents, budget.Period, budget.StartDate, budget.EndDate, budget.IsActive, budget.UpdatedAt, budget.ID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace tags: delete old, insert new
|
||||||
|
if _, err := tx.Exec(`DELETE FROM budget_tags WHERE budget_id = $1;`, budget.ID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tagIDs) > 0 {
|
||||||
|
tagQuery := `INSERT INTO budget_tags (budget_id, tag_id) VALUES ($1, $2);`
|
||||||
|
for _, tagID := range tagIDs {
|
||||||
|
if _, err := tx.Exec(tagQuery, budget.ID, tagID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *budgetRepository) Delete(id string) error {
|
func (r *budgetRepository) Delete(id string) error {
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ import (
|
||||||
|
|
||||||
type CreateBudgetDTO struct {
|
type CreateBudgetDTO struct {
|
||||||
SpaceID string
|
SpaceID string
|
||||||
TagID string
|
TagIDs []string
|
||||||
Amount int
|
Amount int
|
||||||
Period model.BudgetPeriod
|
Period model.BudgetPeriod
|
||||||
StartDate time.Time
|
StartDate time.Time
|
||||||
|
|
@ -21,7 +21,7 @@ type CreateBudgetDTO struct {
|
||||||
|
|
||||||
type UpdateBudgetDTO struct {
|
type UpdateBudgetDTO struct {
|
||||||
ID string
|
ID string
|
||||||
TagID string
|
TagIDs []string
|
||||||
Amount int
|
Amount int
|
||||||
Period model.BudgetPeriod
|
Period model.BudgetPeriod
|
||||||
StartDate time.Time
|
StartDate time.Time
|
||||||
|
|
@ -41,11 +41,14 @@ func (s *BudgetService) CreateBudget(dto CreateBudgetDTO) (*model.Budget, error)
|
||||||
return nil, fmt.Errorf("budget amount must be positive")
|
return nil, fmt.Errorf("budget amount must be positive")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(dto.TagIDs) == 0 {
|
||||||
|
return nil, fmt.Errorf("at least one tag is required")
|
||||||
|
}
|
||||||
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
budget := &model.Budget{
|
budget := &model.Budget{
|
||||||
ID: uuid.NewString(),
|
ID: uuid.NewString(),
|
||||||
SpaceID: dto.SpaceID,
|
SpaceID: dto.SpaceID,
|
||||||
TagID: dto.TagID,
|
|
||||||
AmountCents: dto.Amount,
|
AmountCents: dto.Amount,
|
||||||
Period: dto.Period,
|
Period: dto.Period,
|
||||||
StartDate: dto.StartDate,
|
StartDate: dto.StartDate,
|
||||||
|
|
@ -56,7 +59,7 @@ func (s *BudgetService) CreateBudget(dto CreateBudgetDTO) (*model.Budget, error)
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.budgetRepo.Create(budget); err != nil {
|
if err := s.budgetRepo.Create(budget, dto.TagIDs); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return budget, nil
|
return budget, nil
|
||||||
|
|
@ -66,21 +69,35 @@ func (s *BudgetService) GetBudget(id string) (*model.Budget, error) {
|
||||||
return s.budgetRepo.GetByID(id)
|
return s.budgetRepo.GetByID(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *BudgetService) GetBudgetsWithSpent(spaceID string, tags []*model.Tag) ([]*model.BudgetWithSpent, error) {
|
func (s *BudgetService) GetBudgetsWithSpent(spaceID string) ([]*model.BudgetWithSpent, error) {
|
||||||
budgets, err := s.budgetRepo.GetBySpaceID(spaceID)
|
budgets, err := s.budgetRepo.GetBySpaceID(spaceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
tagMap := make(map[string]*model.Tag)
|
// Collect budget IDs for batch tag fetch
|
||||||
for _, t := range tags {
|
budgetIDs := make([]string, len(budgets))
|
||||||
tagMap[t.ID] = t
|
for i, b := range budgets {
|
||||||
|
budgetIDs[i] = b.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
budgetTagsMap, err := s.budgetRepo.GetTagsByBudgetIDs(budgetIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make([]*model.BudgetWithSpent, 0, len(budgets))
|
result := make([]*model.BudgetWithSpent, 0, len(budgets))
|
||||||
for _, b := range budgets {
|
for _, b := range budgets {
|
||||||
|
tags := budgetTagsMap[b.ID]
|
||||||
|
|
||||||
|
// Extract tag IDs for spending calculation
|
||||||
|
tagIDs := make([]string, len(tags))
|
||||||
|
for i, t := range tags {
|
||||||
|
tagIDs[i] = t.ID
|
||||||
|
}
|
||||||
|
|
||||||
start, end := GetCurrentPeriodBounds(b.Period, time.Now())
|
start, end := GetCurrentPeriodBounds(b.Period, time.Now())
|
||||||
spent, err := s.budgetRepo.GetSpentForBudget(spaceID, b.TagID, start, end)
|
spent, err := s.budgetRepo.GetSpentForBudget(spaceID, tagIDs, start, end)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
spent = 0
|
spent = 0
|
||||||
}
|
}
|
||||||
|
|
@ -102,16 +119,12 @@ func (s *BudgetService) GetBudgetsWithSpent(spaceID string, tags []*model.Tag) (
|
||||||
|
|
||||||
bws := &model.BudgetWithSpent{
|
bws := &model.BudgetWithSpent{
|
||||||
Budget: *b,
|
Budget: *b,
|
||||||
|
Tags: tags,
|
||||||
SpentCents: spent,
|
SpentCents: spent,
|
||||||
Percentage: percentage,
|
Percentage: percentage,
|
||||||
Status: status,
|
Status: status,
|
||||||
}
|
}
|
||||||
|
|
||||||
if tag, ok := tagMap[b.TagID]; ok {
|
|
||||||
bws.TagName = tag.Name
|
|
||||||
bws.TagColor = tag.Color
|
|
||||||
}
|
|
||||||
|
|
||||||
result = append(result, bws)
|
result = append(result, bws)
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
|
|
@ -122,19 +135,22 @@ func (s *BudgetService) UpdateBudget(dto UpdateBudgetDTO) (*model.Budget, error)
|
||||||
return nil, fmt.Errorf("budget amount must be positive")
|
return nil, fmt.Errorf("budget amount must be positive")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(dto.TagIDs) == 0 {
|
||||||
|
return nil, fmt.Errorf("at least one tag is required")
|
||||||
|
}
|
||||||
|
|
||||||
existing, err := s.budgetRepo.GetByID(dto.ID)
|
existing, err := s.budgetRepo.GetByID(dto.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
existing.TagID = dto.TagID
|
|
||||||
existing.AmountCents = dto.Amount
|
existing.AmountCents = dto.Amount
|
||||||
existing.Period = dto.Period
|
existing.Period = dto.Period
|
||||||
existing.StartDate = dto.StartDate
|
existing.StartDate = dto.StartDate
|
||||||
existing.EndDate = dto.EndDate
|
existing.EndDate = dto.EndDate
|
||||||
existing.UpdatedAt = time.Now()
|
existing.UpdatedAt = time.Now()
|
||||||
|
|
||||||
if err := s.budgetRepo.Update(existing); err != nil {
|
if err := s.budgetRepo.Update(existing, dto.TagIDs); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return existing, nil
|
return existing, nil
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,15 @@ func progressBarColor(status model.BudgetStatus) string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func budgetTagSelected(tags []*model.Tag, tagID string) bool {
|
||||||
|
for _, t := range tags {
|
||||||
|
if t.ID == tagID {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
templ SpaceBudgetsPage(space *model.Space, budgets []*model.BudgetWithSpent, tags []*model.Tag) {
|
templ SpaceBudgetsPage(space *model.Space, budgets []*model.BudgetWithSpent, tags []*model.Tag) {
|
||||||
@layouts.Space("Budgets", space) {
|
@layouts.Space("Budgets", space) {
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
|
|
@ -54,7 +63,7 @@ templ SpaceBudgetsPage(space *model.Space, budgets []*model.BudgetWithSpent, tag
|
||||||
Add Budget
|
Add Budget
|
||||||
}
|
}
|
||||||
@dialog.Description() {
|
@dialog.Description() {
|
||||||
Set a spending limit for a tag category.
|
Set a spending limit for one or more tag categories.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@AddBudgetForm(space.ID, tags)
|
@AddBudgetForm(space.ID, tags)
|
||||||
|
|
@ -89,11 +98,15 @@ templ BudgetCard(spaceID string, b *model.BudgetWithSpent, tags []*model.Tag) {
|
||||||
<div id={ "budget-" + b.ID } class="border rounded-lg p-4 bg-card text-card-foreground space-y-3">
|
<div id={ "budget-" + b.ID } class="border rounded-lg p-4 bg-card text-card-foreground space-y-3">
|
||||||
<div class="flex justify-between items-start">
|
<div class="flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
if b.TagColor != nil {
|
for _, t := range b.Tags {
|
||||||
<span class="inline-block w-3 h-3 rounded-full" style={ "background-color: " + *b.TagColor }></span>
|
<span class="inline-flex items-center gap-1">
|
||||||
|
if t.Color != nil {
|
||||||
|
<span class="inline-block w-3 h-3 rounded-full" style={ "background-color: " + *t.Color }></span>
|
||||||
|
}
|
||||||
|
<span class="text-sm font-semibold">{ t.Name }</span>
|
||||||
|
</span>
|
||||||
}
|
}
|
||||||
<h3 class="font-semibold">{ b.TagName }</h3>
|
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-muted-foreground">{ periodLabel(b.Period) } budget</p>
|
<p class="text-xs text-muted-foreground">{ periodLabel(b.Period) } budget</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -128,7 +141,7 @@ templ BudgetCard(spaceID string, b *model.BudgetWithSpent, tags []*model.Tag) {
|
||||||
Delete Budget
|
Delete Budget
|
||||||
}
|
}
|
||||||
@dialog.Description() {
|
@dialog.Description() {
|
||||||
Are you sure you want to delete the budget for "{ b.TagName }"?
|
Are you sure you want to delete this budget?
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@dialog.Footer() {
|
@dialog.Footer() {
|
||||||
|
|
@ -177,14 +190,14 @@ templ AddBudgetForm(spaceID string, tags []*model.Tag) {
|
||||||
class="space-y-4"
|
class="space-y-4"
|
||||||
>
|
>
|
||||||
@csrf.Token()
|
@csrf.Token()
|
||||||
// Tag selector
|
// Tag selector (multi-select)
|
||||||
<div>
|
<div>
|
||||||
@label.Label(label.Props{}) {
|
@label.Label(label.Props{}) {
|
||||||
Tag
|
Tags
|
||||||
}
|
}
|
||||||
@selectbox.SelectBox(selectbox.Props{ID: "budget-tag"}) {
|
@selectbox.SelectBox(selectbox.Props{ID: "budget-tags", Multiple: true}) {
|
||||||
@selectbox.Trigger(selectbox.TriggerProps{Name: "tag_id"}) {
|
@selectbox.Trigger(selectbox.TriggerProps{Name: "tag_ids", Multiple: true, ShowPills: true, SelectedCountText: "{n} tags selected"}) {
|
||||||
@selectbox.Value(selectbox.ValueProps{Placeholder: "Select a tag..."})
|
@selectbox.Value(selectbox.ValueProps{Placeholder: "Select tags..."})
|
||||||
}
|
}
|
||||||
@selectbox.Content() {
|
@selectbox.Content() {
|
||||||
for _, t := range tags {
|
for _, t := range tags {
|
||||||
|
|
@ -285,18 +298,18 @@ templ EditBudgetForm(spaceID string, b *model.BudgetWithSpent, tags []*model.Tag
|
||||||
class="space-y-4"
|
class="space-y-4"
|
||||||
>
|
>
|
||||||
@csrf.Token()
|
@csrf.Token()
|
||||||
// Tag selector
|
// Tag selector (multi-select with pre-selected tags)
|
||||||
<div>
|
<div>
|
||||||
@label.Label(label.Props{}) {
|
@label.Label(label.Props{}) {
|
||||||
Tag
|
Tags
|
||||||
}
|
}
|
||||||
@selectbox.SelectBox(selectbox.Props{ID: "edit-budget-tag-" + b.ID}) {
|
@selectbox.SelectBox(selectbox.Props{ID: "edit-budget-tags-" + b.ID, Multiple: true}) {
|
||||||
@selectbox.Trigger(selectbox.TriggerProps{Name: "tag_id"}) {
|
@selectbox.Trigger(selectbox.TriggerProps{Name: "tag_ids", Multiple: true, ShowPills: true, SelectedCountText: "{n} tags selected"}) {
|
||||||
@selectbox.Value(selectbox.ValueProps{Placeholder: "Select a tag..."})
|
@selectbox.Value(selectbox.ValueProps{Placeholder: "Select tags..."})
|
||||||
}
|
}
|
||||||
@selectbox.Content() {
|
@selectbox.Content() {
|
||||||
for _, t := range tags {
|
for _, t := range tags {
|
||||||
@selectbox.Item(selectbox.ItemProps{Value: t.ID, Selected: t.ID == b.TagID}) {
|
@selectbox.Item(selectbox.ItemProps{Value: t.ID, Selected: budgetTagSelected(b.Tags, t.ID)}) {
|
||||||
{ t.Name }
|
{ t.Name }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -257,11 +257,15 @@ templ overviewBudgetsCard(data OverviewData) {
|
||||||
{{ pct = 100 }}
|
{{ pct = 100 }}
|
||||||
}
|
}
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
if b.TagColor != nil {
|
for _, t := range b.Tags {
|
||||||
<span class="inline-block w-2.5 h-2.5 rounded-full" style={ "background-color: " + *b.TagColor }></span>
|
<span class="inline-flex items-center gap-1">
|
||||||
|
if t.Color != nil {
|
||||||
|
<span class="inline-block w-2.5 h-2.5 rounded-full" style={ "background-color: " + *t.Color }></span>
|
||||||
|
}
|
||||||
|
<span class="text-sm font-medium">{ t.Name }</span>
|
||||||
|
</span>
|
||||||
}
|
}
|
||||||
<span class="text-sm font-medium">{ b.TagName }</span>
|
|
||||||
<span class="text-xs text-muted-foreground ml-auto">{ overviewPeriodLabel(b.Period) }</span>
|
<span class="text-xs text-muted-foreground ml-auto">{ overviewPeriodLabel(b.Period) }</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between text-xs text-muted-foreground">
|
<div class="flex justify-between text-xs text-muted-foreground">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue