From 64e2e80e80a696469d8015601f814aa785b823c9 Mon Sep 17 00:00:00 2001 From: juancwu Date: Sat, 7 Feb 2026 19:03:26 +0000 Subject: [PATCH] feat: link shopping list items --- internal/handler/space.go | 30 +++++- internal/model/shopping_list.go | 5 + internal/service/shopping_list.go | 52 ++++++++++ internal/ui/components/expense/expense.templ | 102 +++++++++++++++++-- internal/ui/pages/app_space_expenses.templ | 4 +- 5 files changed, 181 insertions(+), 12 deletions(-) diff --git a/internal/handler/space.go b/internal/handler/space.go index a825b73..254f580 100644 --- a/internal/handler/space.go +++ b/internal/handler/space.go @@ -422,14 +422,14 @@ func (h *SpaceHandler) ExpensesPage(w http.ResponseWriter, r *http.Request) { return } - lists, err := h.listService.GetListsForSpace(spaceID) + listsWithItems, err := h.listService.GetListsWithUncheckedItems(spaceID) if err != nil { - slog.Error("failed to get lists for space", "error", err, "space_id", spaceID) + slog.Error("failed to get lists with unchecked items", "error", err, "space_id", spaceID) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } - ui.Render(w, r, pages.SpaceExpensesPage(space, expenses, balance, tags, lists)) + ui.Render(w, r, pages.SpaceExpensesPage(space, expenses, balance, tags, listsWithItems)) } func (h *SpaceHandler) CreateExpense(w http.ResponseWriter, r *http.Request) { @@ -513,6 +513,15 @@ func (h *SpaceHandler) CreateExpense(w http.ResponseWriter, r *http.Request) { processedTags[tagName] = true } + // Parse linked shopping list items + itemIDs := r.Form["item_ids"] + itemAction := r.FormValue("item_action") + + // Only link items for expense type, not topup + if expenseType != model.ExpenseTypeExpense { + itemIDs = nil + } + dto := service.CreateExpenseDTO{ SpaceID: spaceID, UserID: user.ID, @@ -521,7 +530,7 @@ func (h *SpaceHandler) CreateExpense(w http.ResponseWriter, r *http.Request) { Type: expenseType, Date: date, TagIDs: finalTagIDs, - ItemIDs: []string{}, // TODO: Add item IDs from form + ItemIDs: itemIDs, } newExpense, err := h.expenseService.CreateExpense(dto) @@ -531,6 +540,19 @@ func (h *SpaceHandler) CreateExpense(w http.ResponseWriter, r *http.Request) { return } + // Process linked items post-creation + for _, itemID := range itemIDs { + if itemAction == "delete" { + if err := h.listService.DeleteItem(itemID); err != nil { + slog.Error("failed to delete linked item", "error", err, "item_id", itemID) + } + } else { + if err := h.listService.CheckItem(itemID); err != nil { + slog.Error("failed to check linked item", "error", err, "item_id", itemID) + } + } + } + balance, err := h.expenseService.GetBalanceForSpace(spaceID) if err != nil { slog.Error("failed to get balance", "error", err, "space_id", spaceID) diff --git a/internal/model/shopping_list.go b/internal/model/shopping_list.go index c9abf59..f0a230f 100644 --- a/internal/model/shopping_list.go +++ b/internal/model/shopping_list.go @@ -20,6 +20,11 @@ type ListItem struct { UpdatedAt time.Time `db:"updated_at"` } +type ListWithUncheckedItems struct { + List *ShoppingList + Items []*ListItem +} + type ListCardData struct { List *ShoppingList Items []*ListItem diff --git a/internal/service/shopping_list.go b/internal/service/shopping_list.go index e0f3633..10f977d 100644 --- a/internal/service/shopping_list.go +++ b/internal/service/shopping_list.go @@ -196,6 +196,58 @@ func (s *ShoppingListService) UpdateItem(itemID, name string, isChecked bool) (* return item, nil } +func (s *ShoppingListService) CheckItem(itemID string) error { + item, err := s.itemRepo.GetByID(itemID) + if err != nil { + return err + } + + item.IsChecked = true + + err = s.itemRepo.Update(item) + if err != nil { + return err + } + + list, err := s.listRepo.GetByID(item.ListID) + if err == nil { + s.eventBus.Publish(list.SpaceID, "item_updated", nil) + } + + return nil +} + +func (s *ShoppingListService) GetListsWithUncheckedItems(spaceID string) ([]model.ListWithUncheckedItems, error) { + lists, err := s.listRepo.GetBySpaceID(spaceID) + if err != nil { + return nil, err + } + + var result []model.ListWithUncheckedItems + for _, list := range lists { + items, err := s.itemRepo.GetByListID(list.ID) + if err != nil { + return nil, err + } + + var unchecked []*model.ListItem + for _, item := range items { + if !item.IsChecked { + unchecked = append(unchecked, item) + } + } + + if len(unchecked) > 0 { + result = append(result, model.ListWithUncheckedItems{ + List: list, + Items: unchecked, + }) + } + } + + return result, nil +} + func (s *ShoppingListService) DeleteItem(itemID string) error { item, err := s.itemRepo.GetByID(itemID) if err != nil { diff --git a/internal/ui/components/expense/expense.templ b/internal/ui/components/expense/expense.templ index 5b4156c..17878aa 100644 --- a/internal/ui/components/expense/expense.templ +++ b/internal/ui/components/expense/expense.templ @@ -2,17 +2,20 @@ package expense import ( "fmt" + "strconv" "git.juancwu.dev/juancwu/budgit/internal/model" "git.juancwu.dev/juancwu/budgit/internal/ui/components/button" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/checkbox" "git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf" - "git.juancwu.dev/juancwu/budgit/internal/ui/components/input" - "git.juancwu.dev/juancwu/budgit/internal/ui/components/tagsinput" - "git.juancwu.dev/juancwu/budgit/internal/ui/components/radio" - "git.juancwu.dev/juancwu/budgit/internal/ui/components/label" "git.juancwu.dev/juancwu/budgit/internal/ui/components/datepicker" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/input" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/label" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/radio" + "git.juancwu.dev/juancwu/budgit/internal/ui/components/tagsinput" ) -templ AddExpenseForm(space *model.Space, tags []*model.Tag, lists []*model.ShoppingList) { +templ AddExpenseForm(space *model.Space, tags []*model.Tag, listsWithItems []model.ListWithUncheckedItems) {
@label.Label(label.Props{ @@ -43,6 +49,9 @@ templ AddExpenseForm(space *model.Space, tags []*model.Tag, lists []*model.Shopp ID: "expense-type-topup", Name: "type", Value: "topup", + Attributes: templ.Attributes{ + "_": "on click hide #item-selector-section", + }, })
@label.Label(label.Props{ @@ -110,7 +119,88 @@ templ AddExpenseForm(space *model.Space, tags []*model.Tag, lists []*model.Shopp Attributes: templ.Attributes{"list": "available-tags"}, })
- // TODO: Shopping list items selector + // Shopping list items selector +
+ @label.Label(label.Props{}) { + Link Shopping List Items + } + if len(listsWithItems) == 0 { +

No unchecked items available.

+ } else { +
+ for i, lwi := range listsWithItems { + {{ toggleID := "toggle-list-" + lwi.List.ID }} + {{ itemsID := "items-" + lwi.List.ID }} +
+
+ @checkbox.Checkbox(checkbox.Props{ + ID: "select-all-" + lwi.List.ID, + Attributes: templ.Attributes{ + "_": "on change repeat for cb in in #" + itemsID + " set cb.checked to my.checked end", + }, + }) + +
+ +
+ if i < len(listsWithItems) - 1 { +
+ } + } +
+ // Post-action radio group +
+

After linking items:

+
+
+ @radio.Radio(radio.Props{ + ID: "item-action-check", + Name: "item_action", + Value: "check", + Checked: true, + }) + @label.Label(label.Props{For: "item-action-check"}) { + Mark as checked + } +
+
+ @radio.Radio(radio.Props{ + ID: "item-action-delete", + Name: "item_action", + Value: "delete", + }) + @label.Label(label.Props{For: "item-action-delete"}) { + Delete from list + } +
+
+
+ } +
@button.Button(button.Props{Type: button.TypeSubmit}) { Save diff --git a/internal/ui/pages/app_space_expenses.templ b/internal/ui/pages/app_space_expenses.templ index 80a155d..7a0327c 100644 --- a/internal/ui/pages/app_space_expenses.templ +++ b/internal/ui/pages/app_space_expenses.templ @@ -9,7 +9,7 @@ import ( "git.juancwu.dev/juancwu/budgit/internal/ui/layouts" ) -templ SpaceExpensesPage(space *model.Space, expenses []*model.Expense, balance int, tags []*model.Tag, lists []*model.ShoppingList) { +templ SpaceExpensesPage(space *model.Space, expenses []*model.Expense, balance int, tags []*model.Tag, listsWithItems []model.ListWithUncheckedItems) { @layouts.Space("Expenses", space) {
@@ -29,7 +29,7 @@ templ SpaceExpensesPage(space *model.Space, expenses []*model.Expense, balance i Add a new expense or top-up to your space. } } - @expense.AddExpenseForm(space, tags, lists) + @expense.AddExpenseForm(space, tags, listsWithItems) } }