feat: extend expense card info and allow edit/delete of expense

This commit is contained in:
juancwu 2026-02-07 14:46:27 -05:00
commit 99a002c607
No known key found for this signature in database
7 changed files with 516 additions and 24 deletions

View file

@ -209,6 +209,106 @@ templ AddExpenseForm(space *model.Space, tags []*model.Tag, listsWithItems []mod
</form>
}
templ EditExpenseForm(spaceID string, exp *model.ExpenseWithTags) {
{{ editDialogID := "edit-expense-" + exp.ID }}
{{ tagValues := make([]string, len(exp.Tags)) }}
for i, t := range exp.Tags {
{{ tagValues[i] = t.Name }}
}
<form
hx-patch={ fmt.Sprintf("/app/spaces/%s/expenses/%s", spaceID, exp.ID) }
hx-target={ "#expense-" + exp.ID }
hx-swap="outerHTML"
_={ "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + editDialogID + "') end" }
class="space-y-4"
>
@csrf.Token()
// Type
<div class="flex gap-4">
<div class="flex items-start gap-3">
@radio.Radio(radio.Props{
ID: "edit-type-expense-" + exp.ID,
Name: "type",
Value: "expense",
Checked: exp.Type == model.ExpenseTypeExpense,
})
<div class="grid gap-2">
@label.Label(label.Props{For: "edit-type-expense-" + exp.ID}) {
Expense
}
</div>
</div>
<div class="flex items-start gap-3">
@radio.Radio(radio.Props{
ID: "edit-type-topup-" + exp.ID,
Name: "type",
Value: "topup",
Checked: exp.Type == model.ExpenseTypeTopup,
})
<div class="grid gap-2">
@label.Label(label.Props{For: "edit-type-topup-" + exp.ID}) {
Top-up
}
</div>
</div>
</div>
// Description
<div>
@label.Label(label.Props{For: "edit-description-" + exp.ID}) {
Description
}
@input.Input(input.Props{
Name: "description",
ID: "edit-description-" + exp.ID,
Value: exp.Description,
Attributes: templ.Attributes{"required": "true"},
})
</div>
// Amount
<div>
@label.Label(label.Props{For: "edit-amount-" + exp.ID}) {
Amount
}
@input.Input(input.Props{
Name: "amount",
ID: "edit-amount-" + exp.ID,
Type: "number",
Value: fmt.Sprintf("%.2f", float64(exp.AmountCents)/100.0),
Attributes: templ.Attributes{"step": "0.01", "required": "true"},
})
</div>
// Date
<div>
@label.Label(label.Props{For: "edit-date-" + exp.ID}) {
Date
}
@datepicker.DatePicker(datepicker.Props{
ID: "edit-date-" + exp.ID,
Name: "date",
Value: exp.Date,
Attributes: templ.Attributes{"required": "true"},
})
</div>
// Tags
<div>
@label.Label(label.Props{For: "edit-tags-" + exp.ID}) {
Tags
}
@tagsinput.TagsInput(tagsinput.Props{
ID: "edit-tags-" + exp.ID,
Name: "tags",
Value: tagValues,
Placeholder: "Add tags (press enter)",
})
</div>
<div class="flex justify-end">
@button.Button(button.Props{Type: button.TypeSubmit}) {
Save
}
</div>
</form>
}
templ BalanceCard(spaceID string, balance int, oob bool) {
<div
id="balance-card"

View file

@ -3,13 +3,15 @@ package pages
import (
"fmt"
"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/layouts"
)
templ SpaceExpensesPage(space *model.Space, expenses []*model.Expense, balance int, tags []*model.Tag, listsWithItems []model.ListWithUncheckedItems) {
templ SpaceExpensesPage(space *model.Space, expenses []*model.ExpenseWithTags, balance int, tags []*model.Tag, listsWithItems []model.ListWithUncheckedItems) {
@layouts.Space("Expenses", space) {
<div class="space-y-4">
<div class="flex justify-between items-center">
@ -42,45 +44,108 @@ templ SpaceExpensesPage(space *model.Space, expenses []*model.Expense, balance i
hx-trigger="sse:expenses_updated"
hx-swap="innerHTML"
>
@ExpensesListContent(expenses)
@ExpensesListContent(space.ID, expenses)
</div>
</div>
}
}
templ ExpensesListContent(expenses []*model.Expense) {
templ ExpensesListContent(spaceID string, expenses []*model.ExpenseWithTags) {
<h2 class="text-lg font-semibold p-4">History</h2>
<div id="expenses-list" class="divide-y">
if len(expenses) == 0 {
<p class="p-4 text-sm text-muted-foreground">No expenses recorded yet.</p>
}
for _, expense := range expenses {
@ExpenseListItem(expense)
for _, exp := range expenses {
@ExpenseListItem(spaceID, exp)
}
</div>
}
templ ExpenseListItem(expense *model.Expense) {
<div class="p-4 flex justify-between items-center">
<div>
<p class="font-medium">{ expense.Description }</p>
<p class="text-sm text-muted-foreground">{ expense.Date.Format("Jan 02, 2006") }</p>
templ ExpenseListItem(spaceID string, exp *model.ExpenseWithTags) {
<div id={ "expense-" + exp.ID } class="p-4 flex justify-between items-start gap-2">
<div class="min-w-0 flex-1">
<p class="font-medium">{ exp.Description }</p>
<p class="text-sm text-muted-foreground">{ exp.Date.Format("Jan 02, 2006") }</p>
if len(exp.Tags) > 0 {
<div class="flex flex-wrap gap-1 mt-1">
for _, t := range exp.Tags {
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
{ t.Name }
}
}
</div>
}
</div>
<div>
if expense.Type == model.ExpenseTypeExpense {
<div class="flex items-center gap-1 shrink-0">
if exp.Type == model.ExpenseTypeExpense {
<p class="font-bold text-destructive">
- { fmt.Sprintf("$%.2f", float64(expense.AmountCents)/100.0) }
- { fmt.Sprintf("$%.2f", float64(exp.AmountCents)/100.0) }
</p>
} else {
<p class="font-bold text-green-500">
+ { fmt.Sprintf("$%.2f", float64(expense.AmountCents)/100.0) }
+ { fmt.Sprintf("$%.2f", float64(exp.AmountCents)/100.0) }
</p>
}
// Edit button
@dialog.Dialog(dialog.Props{ID: "edit-expense-" + exp.ID}) {
@dialog.Trigger() {
@button.Button(button.Props{Variant: button.VariantGhost, Size: button.SizeIcon, Class: "size-7"}) {
@icon.Pencil(icon.Props{Size: 14})
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Edit Transaction
}
@dialog.Description() {
Update the details of this transaction.
}
}
@expense.EditExpenseForm(spaceID, exp)
}
}
// Delete button
@dialog.Dialog(dialog.Props{ID: "del-expense-" + exp.ID}) {
@dialog.Trigger() {
@button.Button(button.Props{Variant: button.VariantGhost, Size: button.SizeIcon, Class: "size-7"}) {
@icon.Trash2(icon.Props{Size: 14})
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Delete Transaction
}
@dialog.Description() {
Are you sure you want to delete "{ exp.Description }"? This action cannot be undone.
}
}
@dialog.Footer() {
@dialog.Close() {
@button.Button(button.Props{Variant: button.VariantOutline}) {
Cancel
}
}
@button.Button(button.Props{
Variant: button.VariantDestructive,
Attributes: templ.Attributes{
"hx-delete": fmt.Sprintf("/app/spaces/%s/expenses/%s", spaceID, exp.ID),
"hx-target": "#expense-" + exp.ID,
"hx-swap": "outerHTML",
},
}) {
Delete
}
}
}
}
</div>
</div>
}
templ ExpenseCreatedResponse(newExpense *model.Expense, balance int) {
@ExpenseListItem(newExpense)
templ ExpenseCreatedResponse(spaceID string, newExpense *model.ExpenseWithTags, balance int) {
@ExpenseListItem(spaceID, newExpense)
@expense.BalanceCard(newExpense.SpaceID, balance, true)
}