feat: paginate expenses history

This commit is contained in:
juancwu 2026-02-09 12:40:02 +00:00
commit 153eaca676
5 changed files with 237 additions and 100 deletions

View file

@ -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) {
})
</div>
// Shopping list items selector
<div id="item-selector-section">
@label.Label(label.Props{}) {
Link Shopping List Items
}
if len(props.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 props.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(props.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>
@ItemSelectorSection(props.ListsWithItems, false)
<div class="flex justify-end">
@button.Button(button.Props{Type: button.TypeSubmit}) {
Save
@ -332,6 +252,95 @@ templ EditExpenseForm(spaceID string, exp *model.ExpenseWithTags) {
</form>
}
templ ItemSelectorSection(listsWithItems []model.ListWithUncheckedItems, oob bool) {
<div
id="item-selector-section"
if oob {
hx-swap-oob="true"
}
>
@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>
}
templ BalanceCard(spaceID string, balance int, oob bool) {
<div
id="balance-card"

View file

@ -2,16 +2,18 @@ package pages
import (
"fmt"
"strconv"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/expense"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/pagination"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
templ SpaceExpensesPage(space *model.Space, expenses []*model.ExpenseWithTags, balance int, tags []*model.Tag, listsWithItems []model.ListWithUncheckedItems) {
templ SpaceExpensesPage(space *model.Space, expenses []*model.ExpenseWithTags, balance int, tags []*model.Tag, listsWithItems []model.ListWithUncheckedItems, currentPage, totalPages int) {
@layouts.Space("Expenses", space) {
<div class="space-y-4">
<div class="flex justify-between items-center">
@ -44,13 +46,15 @@ templ SpaceExpensesPage(space *model.Space, expenses []*model.ExpenseWithTags, b
@expense.BalanceCard(space.ID, balance, false)
// List of expenses
<div class="border rounded-lg">
@ExpensesListContent(space.ID, expenses)
<div id="expenses-list-wrapper">
@ExpensesListContent(space.ID, expenses, currentPage, totalPages)
</div>
</div>
</div>
}
}
templ ExpensesListContent(spaceID string, expenses []*model.ExpenseWithTags) {
templ ExpensesListContent(spaceID string, expenses []*model.ExpenseWithTags, currentPage, totalPages int) {
<h2 class="text-lg font-semibold p-4">History</h2>
<div id="expenses-list" class="divide-y">
if len(expenses) == 0 {
@ -60,6 +64,48 @@ templ ExpensesListContent(spaceID string, expenses []*model.ExpenseWithTags) {
@ExpenseListItem(spaceID, exp)
}
</div>
if totalPages > 1 {
<div class="border-t p-2">
@pagination.Pagination(pagination.Props{Class: "justify-center"}) {
@pagination.Content() {
@pagination.Item() {
@pagination.Previous(pagination.PreviousProps{
Disabled: currentPage <= 1,
Attributes: templ.Attributes{
"hx-get": fmt.Sprintf("/app/spaces/%s/components/expenses?page=%d", spaceID, currentPage-1),
"hx-target": "#expenses-list-wrapper",
"hx-swap": "innerHTML",
},
})
}
for _, pg := range pagination.CreatePagination(currentPage, totalPages, 3).Pages {
@pagination.Item() {
@pagination.Link(pagination.LinkProps{
IsActive: pg == currentPage,
Attributes: templ.Attributes{
"hx-get": fmt.Sprintf("/app/spaces/%s/components/expenses?page=%d", spaceID, pg),
"hx-target": "#expenses-list-wrapper",
"hx-swap": "innerHTML",
},
}) {
{ strconv.Itoa(pg) }
}
}
}
@pagination.Item() {
@pagination.Next(pagination.NextProps{
Disabled: currentPage >= totalPages,
Attributes: templ.Attributes{
"hx-get": fmt.Sprintf("/app/spaces/%s/components/expenses?page=%d", spaceID, currentPage+1),
"hx-target": "#expenses-list-wrapper",
"hx-swap": "innerHTML",
},
})
}
}
}
</div>
}
}
templ ExpenseListItem(spaceID string, exp *model.ExpenseWithTags) {
@ -145,9 +191,9 @@ templ ExpenseListItem(spaceID string, exp *model.ExpenseWithTags) {
</div>
}
templ ExpenseCreatedResponse(spaceID string, newExpense *model.ExpenseWithTags, balance int) {
@ExpenseListItem(spaceID, newExpense)
@expense.BalanceCard(newExpense.SpaceID, balance, true)
templ ExpenseCreatedResponse(spaceID string, expenses []*model.ExpenseWithTags, balance int, currentPage, totalPages int) {
@ExpensesListContent(spaceID, expenses, currentPage, totalPages)
@expense.BalanceCard(spaceID, balance, true)
}
templ ExpenseUpdatedResponse(spaceID string, exp *model.ExpenseWithTags, balance int) {