From 64e2e80e80a696469d8015601f814aa785b823c9 Mon Sep 17 00:00:00 2001 From: juancwu Date: Sat, 7 Feb 2026 19:03:26 +0000 Subject: [PATCH 1/2] 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) } }
From 2efb5612decabb8087ede06d5b8e4b4df698eec0 Mon Sep 17 00:00:00 2001 From: juancwu Date: Sat, 7 Feb 2026 19:07:54 +0000 Subject: [PATCH 2/2] fix: list items toggle hard to open the list items toggle did not expand the full width so it was hard to open on mobile as one would need to always touch on the name --- internal/ui/components/expense/expense.templ | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/ui/components/expense/expense.templ b/internal/ui/components/expense/expense.templ index 17878aa..0cf2e7e 100644 --- a/internal/ui/components/expense/expense.templ +++ b/internal/ui/components/expense/expense.templ @@ -142,7 +142,7 @@ templ AddExpenseForm(space *model.Space, tags []*model.Tag, listsWithItems []mod