Merge pull request 'feat/link-expense-with-shop-lists' (#1) from feat/link-expense-with-shop-lists into main
Reviewed-on: #1
This commit is contained in:
commit
a638e8d622
5 changed files with 181 additions and 12 deletions
|
|
@ -422,14 +422,14 @@ func (h *SpaceHandler) ExpensesPage(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
lists, err := h.listService.GetListsForSpace(spaceID)
|
listsWithItems, err := h.listService.GetListsWithUncheckedItems(spaceID)
|
||||||
if err != nil {
|
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)
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
return
|
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) {
|
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
|
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{
|
dto := service.CreateExpenseDTO{
|
||||||
SpaceID: spaceID,
|
SpaceID: spaceID,
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
|
|
@ -521,7 +530,7 @@ func (h *SpaceHandler) CreateExpense(w http.ResponseWriter, r *http.Request) {
|
||||||
Type: expenseType,
|
Type: expenseType,
|
||||||
Date: date,
|
Date: date,
|
||||||
TagIDs: finalTagIDs,
|
TagIDs: finalTagIDs,
|
||||||
ItemIDs: []string{}, // TODO: Add item IDs from form
|
ItemIDs: itemIDs,
|
||||||
}
|
}
|
||||||
|
|
||||||
newExpense, err := h.expenseService.CreateExpense(dto)
|
newExpense, err := h.expenseService.CreateExpense(dto)
|
||||||
|
|
@ -531,6 +540,19 @@ func (h *SpaceHandler) CreateExpense(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
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)
|
balance, err := h.expenseService.GetBalanceForSpace(spaceID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to get balance", "error", err, "space_id", spaceID)
|
slog.Error("failed to get balance", "error", err, "space_id", spaceID)
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,11 @@ type ListItem struct {
|
||||||
UpdatedAt time.Time `db:"updated_at"`
|
UpdatedAt time.Time `db:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ListWithUncheckedItems struct {
|
||||||
|
List *ShoppingList
|
||||||
|
Items []*ListItem
|
||||||
|
}
|
||||||
|
|
||||||
type ListCardData struct {
|
type ListCardData struct {
|
||||||
List *ShoppingList
|
List *ShoppingList
|
||||||
Items []*ListItem
|
Items []*ListItem
|
||||||
|
|
|
||||||
|
|
@ -196,6 +196,58 @@ func (s *ShoppingListService) UpdateItem(itemID, name string, isChecked bool) (*
|
||||||
return item, nil
|
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 {
|
func (s *ShoppingListService) DeleteItem(itemID string) error {
|
||||||
item, err := s.itemRepo.GetByID(itemID)
|
item, err := s.itemRepo.GetByID(itemID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,20 @@ package expense
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
"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/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/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) {
|
||||||
<form
|
<form
|
||||||
hx-post={ "/app/spaces/" + space.ID + "/expenses" }
|
hx-post={ "/app/spaces/" + space.ID + "/expenses" }
|
||||||
hx-target="#expenses-list"
|
hx-target="#expenses-list"
|
||||||
|
|
@ -29,6 +32,9 @@ templ AddExpenseForm(space *model.Space, tags []*model.Tag, lists []*model.Shopp
|
||||||
Name: "type",
|
Name: "type",
|
||||||
Value: "expense",
|
Value: "expense",
|
||||||
Checked: true,
|
Checked: true,
|
||||||
|
Attributes: templ.Attributes{
|
||||||
|
"_": "on click show #item-selector-section",
|
||||||
|
},
|
||||||
})
|
})
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
@label.Label(label.Props{
|
@label.Label(label.Props{
|
||||||
|
|
@ -43,6 +49,9 @@ templ AddExpenseForm(space *model.Space, tags []*model.Tag, lists []*model.Shopp
|
||||||
ID: "expense-type-topup",
|
ID: "expense-type-topup",
|
||||||
Name: "type",
|
Name: "type",
|
||||||
Value: "topup",
|
Value: "topup",
|
||||||
|
Attributes: templ.Attributes{
|
||||||
|
"_": "on click hide #item-selector-section",
|
||||||
|
},
|
||||||
})
|
})
|
||||||
<div class="grid gap-2">
|
<div class="grid gap-2">
|
||||||
@label.Label(label.Props{
|
@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"},
|
Attributes: templ.Attributes{"list": "available-tags"},
|
||||||
})
|
})
|
||||||
</div>
|
</div>
|
||||||
// TODO: Shopping list items selector
|
// Shopping list items selector
|
||||||
|
<div id="item-selector-section">
|
||||||
|
@label.Label(label.Props{}) {
|
||||||
|
Link Shopping List Items
|
||||||
|
}
|
||||||
|
if len(listsWithItems) == 0 {
|
||||||
|
<p class="text-sm text-muted-foreground">No unchecked items available.</p>
|
||||||
|
} else {
|
||||||
|
<div class="max-h-48 overflow-y-auto border rounded-md p-2 space-y-2">
|
||||||
|
for i, lwi := range listsWithItems {
|
||||||
|
{{ toggleID := "toggle-list-" + lwi.List.ID }}
|
||||||
|
{{ itemsID := "items-" + lwi.List.ID }}
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
@checkbox.Checkbox(checkbox.Props{
|
||||||
|
ID: "select-all-" + lwi.List.ID,
|
||||||
|
Attributes: templ.Attributes{
|
||||||
|
"_": "on change repeat for cb in <input[name='item_ids']/> in #" + itemsID + " set cb.checked to my.checked end",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
id={ toggleID }
|
||||||
|
class="flex-1 flex items-center gap-1 text-sm font-medium cursor-pointer select-none"
|
||||||
|
_={ "on click toggle .hidden on #" + itemsID + " then toggle .rotate-90 on <svg/> in me" }
|
||||||
|
>
|
||||||
|
@icon.ChevronRight(icon.Props{Size: 14})
|
||||||
|
{ lwi.List.Name }
|
||||||
|
<span class="text-muted-foreground">
|
||||||
|
({ strconv.Itoa(len(lwi.Items)) })
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id={ itemsID } class="hidden pl-6 space-y-1">
|
||||||
|
for _, item := range lwi.Items {
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
@checkbox.Checkbox(checkbox.Props{
|
||||||
|
ID: "item-cb-" + item.ID,
|
||||||
|
Name: "item_ids",
|
||||||
|
Value: item.ID,
|
||||||
|
})
|
||||||
|
<label for={ "item-cb-" + item.ID } class="text-sm cursor-pointer select-none">
|
||||||
|
{ item.Name }
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
if i < len(listsWithItems) - 1 {
|
||||||
|
<hr class="border-border"/>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
// Post-action radio group
|
||||||
|
<div class="mt-2 space-y-1">
|
||||||
|
<p class="text-sm text-muted-foreground">After linking items:</p>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
@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
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
@radio.Radio(radio.Props{
|
||||||
|
ID: "item-action-delete",
|
||||||
|
Name: "item_action",
|
||||||
|
Value: "delete",
|
||||||
|
})
|
||||||
|
@label.Label(label.Props{For: "item-action-delete"}) {
|
||||||
|
Delete from list
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
@button.Button(button.Props{Type: button.TypeSubmit}) {
|
@button.Button(button.Props{Type: button.TypeSubmit}) {
|
||||||
Save
|
Save
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import (
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
"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) {
|
@layouts.Space("Expenses", space) {
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
|
|
@ -29,7 +29,7 @@ templ SpaceExpensesPage(space *model.Space, expenses []*model.Expense, balance i
|
||||||
Add a new expense or top-up to your space.
|
Add a new expense or top-up to your space.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@expense.AddExpenseForm(space, tags, lists)
|
@expense.AddExpenseForm(space, tags, listsWithItems)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue