diff --git a/internal/handler/space.go b/internal/handler/space.go index 9a58f4c..10342db 100644 --- a/internal/handler/space.go +++ b/internal/handler/space.go @@ -382,7 +382,12 @@ func (h *SpaceHandler) ExpensesPage(w http.ResponseWriter, r *http.Request) { return } - expenses, err := h.expenseService.GetExpensesWithTagsForSpace(spaceID) + page := 1 + if p, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && p > 0 { + page = p + } + + expenses, totalPages, err := h.expenseService.GetExpensesWithTagsForSpacePaginated(spaceID, page) if err != nil { slog.Error("failed to get expenses for space", "error", err, "space_id", spaceID) http.Error(w, "Internal Server Error", http.StatusInternalServerError) @@ -410,7 +415,7 @@ func (h *SpaceHandler) ExpensesPage(w http.ResponseWriter, r *http.Request) { return } - ui.Render(w, r, pages.SpaceExpensesPage(space, expenses, balance, tags, listsWithItems)) + ui.Render(w, r, pages.SpaceExpensesPage(space, expenses, balance, tags, listsWithItems, page, totalPages)) if r.URL.Query().Get("created") == "true" { ui.Render(w, r, toast.Toast(toast.Props{ @@ -525,7 +530,7 @@ func (h *SpaceHandler) CreateExpense(w http.ResponseWriter, r *http.Request) { ItemIDs: itemIDs, } - newExpense, err := h.expenseService.CreateExpense(dto) + _, err = h.expenseService.CreateExpense(dto) if err != nil { slog.Error("failed to create expense", "error", err) http.Error(w, "Failed to create expense.", http.StatusInternalServerError) @@ -556,14 +561,23 @@ func (h *SpaceHandler) CreateExpense(w http.ResponseWriter, r *http.Request) { return } - // Build tags for the newly created expense - tagsMap, _ := h.expenseService.GetTagsByExpenseIDs([]string{newExpense.ID}) - newExpenseWithTags := &model.ExpenseWithTags{ - Expense: *newExpense, - Tags: tagsMap[newExpense.ID], + // Return the full paginated list for page 1 so the new expense appears + expenses, totalPages, err := h.expenseService.GetExpensesWithTagsForSpacePaginated(spaceID, 1) + if err != nil { + slog.Error("failed to get paginated expenses after create", "error", err, "space_id", spaceID) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return } - ui.Render(w, r, pages.ExpenseCreatedResponse(spaceID, newExpenseWithTags, balance)) + ui.Render(w, r, pages.ExpenseCreatedResponse(spaceID, expenses, balance, 1, totalPages)) + + // OOB-swap the item selector with fresh data (items may have been deleted/checked) + listsWithItems, err := h.listService.GetListsWithUncheckedItems(spaceID) + if err != nil { + slog.Error("failed to refresh lists with items after create", "error", err, "space_id", spaceID) + return + } + ui.Render(w, r, expense.ItemSelectorSection(listsWithItems, true)) } func (h *SpaceHandler) UpdateExpense(w http.ResponseWriter, r *http.Request) { @@ -774,14 +788,19 @@ func (h *SpaceHandler) GetBalanceCard(w http.ResponseWriter, r *http.Request) { func (h *SpaceHandler) GetExpensesList(w http.ResponseWriter, r *http.Request) { spaceID := r.PathValue("spaceID") - expenses, err := h.expenseService.GetExpensesWithTagsForSpace(spaceID) + page := 1 + if p, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && p > 0 { + page = p + } + + expenses, totalPages, err := h.expenseService.GetExpensesWithTagsForSpacePaginated(spaceID, page) if err != nil { slog.Error("failed to get expenses", "error", err, "space_id", spaceID) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } - ui.Render(w, r, pages.ExpensesListContent(spaceID, expenses)) + ui.Render(w, r, pages.ExpensesListContent(spaceID, expenses, page, totalPages)) } func (h *SpaceHandler) GetShoppingListItems(w http.ResponseWriter, r *http.Request) { diff --git a/internal/repository/expense.go b/internal/repository/expense.go index 18588eb..eefc50a 100644 --- a/internal/repository/expense.go +++ b/internal/repository/expense.go @@ -17,6 +17,8 @@ type ExpenseRepository interface { Create(expense *model.Expense, tagIDs []string, itemIDs []string) error GetByID(id string) (*model.Expense, error) GetBySpaceID(spaceID string) ([]*model.Expense, error) + GetBySpaceIDPaginated(spaceID string, limit, offset int) ([]*model.Expense, error) + CountBySpaceID(spaceID string) (int, error) GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error) GetTagsByExpenseIDs(expenseIDs []string) (map[string][]*model.Tag, error) Update(expense *model.Expense, tagIDs []string) error @@ -91,6 +93,22 @@ func (r *expenseRepository) GetBySpaceID(spaceID string) ([]*model.Expense, erro return expenses, nil } +func (r *expenseRepository) GetBySpaceIDPaginated(spaceID string, limit, offset int) ([]*model.Expense, error) { + var expenses []*model.Expense + query := `SELECT * FROM expenses WHERE space_id = $1 ORDER BY date DESC, created_at DESC LIMIT $2 OFFSET $3;` + err := r.db.Select(&expenses, query, spaceID, limit, offset) + if err != nil { + return nil, err + } + return expenses, nil +} + +func (r *expenseRepository) CountBySpaceID(spaceID string) (int, error) { + var count int + err := r.db.Get(&count, `SELECT COUNT(*) FROM expenses WHERE space_id = $1;`, spaceID) + return count, err +} + func (r *expenseRepository) GetExpensesByTag(spaceID string, fromDate, toDate time.Time) ([]*model.TagExpenseSummary, error) { var summaries []*model.TagExpenseSummary query := ` diff --git a/internal/service/expense.go b/internal/service/expense.go index a7678d1..98ea0ae 100644 --- a/internal/service/expense.go +++ b/internal/service/expense.go @@ -30,6 +30,8 @@ type UpdateExpenseDTO struct { TagIDs []string } +const ExpensesPerPage = 25 + type ExpenseService struct { expenseRepo repository.ExpenseRepository } @@ -121,6 +123,49 @@ func (s *ExpenseService) GetExpensesWithTagsForSpace(spaceID string) ([]*model.E return result, nil } +func (s *ExpenseService) GetExpensesWithTagsForSpacePaginated(spaceID string, page int) ([]*model.ExpenseWithTags, int, error) { + total, err := s.expenseRepo.CountBySpaceID(spaceID) + if err != nil { + return nil, 0, err + } + + totalPages := (total + ExpensesPerPage - 1) / ExpensesPerPage + if totalPages < 1 { + totalPages = 1 + } + if page < 1 { + page = 1 + } + if page > totalPages { + page = totalPages + } + + offset := (page - 1) * ExpensesPerPage + expenses, err := s.expenseRepo.GetBySpaceIDPaginated(spaceID, ExpensesPerPage, offset) + if err != nil { + return nil, 0, err + } + + ids := make([]string, len(expenses)) + for i, e := range expenses { + ids[i] = e.ID + } + + tagsMap, err := s.expenseRepo.GetTagsByExpenseIDs(ids) + if err != nil { + return nil, 0, err + } + + result := make([]*model.ExpenseWithTags, len(expenses)) + for i, e := range expenses { + result[i] = &model.ExpenseWithTags{ + Expense: *e, + Tags: tagsMap[e.ID], + } + } + return result, totalPages, nil +} + func (s *ExpenseService) GetExpense(id string) (*model.Expense, error) { return s.expenseRepo.GetByID(id) } diff --git a/internal/ui/components/expense/expense.templ b/internal/ui/components/expense/expense.templ index 97ea942..8766938 100644 --- a/internal/ui/components/expense/expense.templ +++ b/internal/ui/components/expense/expense.templ @@ -35,8 +35,8 @@ func (p AddExpenseFormProps) formAttrs() templ.Attributes { } return templ.Attributes{ "hx-post": "/app/spaces/" + p.Space.ID + "/expenses", - "hx-target": "#expenses-list", - "hx-swap": "afterbegin", + "hx-target": "#expenses-list-wrapper", + "hx-swap": "innerHTML", "_": closeScript, } } @@ -143,87 +143,7 @@ templ AddExpenseForm(props AddExpenseFormProps) { }) // Shopping list items selector -
No unchecked items available.
- } else { -After linking items:
-No unchecked items available.
+ } else { +After linking items:
+