feat: show recent transactions and view all transactions

This commit is contained in:
juancwu 2026-05-03 17:59:41 +00:00
commit 054f1227f0
8 changed files with 313 additions and 12 deletions

View file

@ -51,7 +51,7 @@ func main() {
})
srv := &http.Server{
Addr: ":" + cfg.Port,
Addr: cfg.Host + ":" + cfg.Port,
Handler: finalHandler,
}

View file

@ -3,6 +3,7 @@ package handler
import (
"log/slog"
"net/http"
"strconv"
"strings"
"time"
@ -193,11 +194,76 @@ func (h *spaceHandler) SpaceAccountPage(w http.ResponseWriter, r *http.Request)
return
}
recent, err := h.transactionService.ListByAccount(accountID, 5, 0)
if err != nil {
slog.Error("failed to load recent transactions", "error", err, "account_id", accountID)
recent = nil
}
ui.Render(w, r, pages.SpaceAccountPage(pages.SpaceAccountPageProps{
SpaceID: spaceID,
AccountID: accountID,
AccountName: account.Name,
AccountBalance: account.Balance,
SpaceID: spaceID,
AccountID: accountID,
AccountName: account.Name,
AccountBalance: account.Balance,
RecentTransactions: recent,
}))
}
func (h *spaceHandler) SpaceAccountTransactionsPage(w http.ResponseWriter, r *http.Request) {
spaceID := r.PathValue("spaceID")
accountID := r.PathValue("accountID")
account, err := h.accountService.GetAccount(accountID)
if err != nil {
slog.Error("failed to load account", "error", err, "account_id", accountID)
ui.Render(w, r, pages.NotFound())
return
}
if account.SpaceID != spaceID {
ui.Render(w, r, pages.NotFound())
return
}
const perPage = 25
page := 1
if p := strings.TrimSpace(r.URL.Query().Get("page")); p != "" {
if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 {
page = parsed
}
}
total, err := h.transactionService.CountByAccount(accountID)
if err != nil {
slog.Error("failed to count transactions", "error", err, "account_id", accountID)
ui.RenderError(w, r, "Failed to load transactions", http.StatusInternalServerError)
return
}
totalPages := (total + perPage - 1) / perPage
if totalPages < 1 {
totalPages = 1
}
if page > totalPages {
page = totalPages
}
offset := (page - 1) * perPage
txns, err := h.transactionService.ListByAccount(accountID, perPage, offset)
if err != nil {
slog.Error("failed to load transactions", "error", err, "account_id", accountID)
ui.RenderError(w, r, "Failed to load transactions", http.StatusInternalServerError)
return
}
ui.Render(w, r, pages.SpaceAccountTransactionsPage(pages.SpaceAccountTransactionsPageProps{
SpaceID: spaceID,
AccountID: accountID,
AccountName: account.Name,
Transactions: txns,
CurrentPage: page,
TotalPages: totalPages,
TotalCount: total,
PerPage: perPage,
}))
}

View file

@ -10,6 +10,8 @@ import (
type TransactionRepository interface {
CreateBillAtomic(t *model.Transaction, newBalance decimal.Decimal, categoryID *string) error
ListByAccount(accountID string, limit, offset int) ([]*model.Transaction, error)
CountByAccount(accountID string) (int, error)
}
type transactionRepository struct {
@ -51,3 +53,26 @@ func (r *transactionRepository) CreateBillAtomic(t *model.Transaction, newBalanc
return nil
})
}
func (r *transactionRepository) ListByAccount(accountID string, limit, offset int) ([]*model.Transaction, error) {
query := `
SELECT id, value, type, account_id, title, description, occurred_at, created_at, updated_at
FROM transactions
WHERE account_id = $1
ORDER BY occurred_at DESC, created_at DESC
LIMIT $2 OFFSET $3;
`
txns := []*model.Transaction{}
if err := r.db.Select(&txns, query, accountID, limit, offset); err != nil {
return nil, err
}
return txns, nil
}
func (r *transactionRepository) CountByAccount(accountID string) (int, error) {
var count int
if err := r.db.Get(&count, `SELECT COUNT(*) FROM transactions WHERE account_id = $1;`, accountID); err != nil {
return 0, err
}
return count, nil
}

View file

@ -95,6 +95,7 @@ func SetupRoutes(a *app.App) http.Handler {
g.SubGroup("/accounts/{accountID}", func(g *router.Group) {
g.Get("/overview", spaceH.SpaceAccountPage).Name("page.app.spaces.space.accounts.account.overview")
g.Get("/transactions", spaceH.SpaceAccountTransactionsPage).Name("page.app.spaces.space.accounts.account.transactions")
g.Get("/bills/create", spaceH.SpaceCreateBillPage).Name("page.app.spaces.space.accounts.account.bills.create")
g.Post("/bills/create", spaceH.HandleCreateBill).Name("action.app.spaces.space.accounts.account.bills.create")
})

View file

@ -89,6 +89,28 @@ func (s *TransactionService) PayBill(input PayBillInput) (*model.Transaction, er
return txn, nil
}
func (s *TransactionService) ListByAccount(accountID string, limit, offset int) ([]*model.Transaction, error) {
if limit <= 0 {
limit = 25
}
if offset < 0 {
offset = 0
}
txns, err := s.transactionRepo.ListByAccount(accountID, limit, offset)
if err != nil {
return nil, fmt.Errorf("failed to list transactions: %w", err)
}
return txns, nil
}
func (s *TransactionService) CountByAccount(accountID string) (int, error) {
count, err := s.transactionRepo.CountByAccount(accountID)
if err != nil {
return 0, fmt.Errorf("failed to count transactions: %w", err)
}
return count, nil
}
func (s *TransactionService) ListCategories() ([]*model.Category, error) {
categories, err := s.categoryRepo.All()
if err != nil {

View file

@ -0,0 +1,56 @@
package blocks
import "git.juancwu.dev/juancwu/budgit/internal/model"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
import "git.juancwu.dev/juancwu/budgit/internal/ui/utils"
templ TransactionList(txns []*model.Transaction) {
if len(txns) == 0 {
<div class="text-sm text-muted-foreground py-6 text-center">
No transactions yet.
</div>
} else {
<ul class="divide-y">
for _, t := range txns {
@transactionRow(t)
}
</ul>
}
}
templ transactionRow(t *model.Transaction) {
{{
isDeposit := t.Type == model.TransactionTypeDeposit
amountClasses := []string{"text-sm font-semibold tabular-nums"}
sign := "-"
if isDeposit {
amountClasses = append(amountClasses, "text-green-600 dark:text-green-400")
sign = "+"
} else {
amountClasses = append(amountClasses, "text-red-600 dark:text-red-400")
}
}}
<li class="flex items-center justify-between gap-4 py-3">
<div class="flex items-center gap-3 min-w-0">
<div class="w-9 h-9 shrink-0 rounded-full bg-muted flex items-center justify-center">
if isDeposit {
@icon.BanknoteArrowDown(icon.Props{Class: "size-4 text-green-600 dark:text-green-400"})
} else {
@icon.HandCoins(icon.Props{Class: "size-4 text-red-600 dark:text-red-400"})
}
</div>
<div class="min-w-0">
<p class="font-medium truncate">{ t.Title }</p>
<p class="text-xs text-muted-foreground">{ t.OccurredAt.Format("Jan 2, 2006") }</p>
</div>
</div>
<div class="text-right shrink-0">
<p class={ utils.TwMerge(amountClasses...) }>
{ sign }${ utils.FormatDecimalWithThousands(t.Value.StringFixedBank(2)) }
</p>
if t.Description != nil && *t.Description != "" {
<p class="text-xs text-muted-foreground truncate max-w-[200px]">{ *t.Description }</p>
}
</div>
</li>
}

View file

@ -1,7 +1,9 @@
package pages
import "github.com/shopspring/decimal"
import "git.juancwu.dev/juancwu/budgit/internal/model"
import "git.juancwu.dev/juancwu/budgit/internal/routeurl"
import "git.juancwu.dev/juancwu/budgit/internal/ui/blocks"
import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
@ -9,10 +11,11 @@ import "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
import "git.juancwu.dev/juancwu/budgit/internal/ui/utils"
type SpaceAccountPageProps struct {
SpaceID string
AccountID string
AccountName string
AccountBalance decimal.Decimal
SpaceID string
AccountID string
AccountName string
AccountBalance decimal.Decimal
RecentTransactions []*model.Transaction
}
templ SpaceAccountPage(props SpaceAccountPageProps) {
@ -43,7 +46,7 @@ templ SpaceAccountPage(props SpaceAccountPageProps) {
@card.Card(card.Props{Class: "rounded-sm col-span-full md:col-span-4"}) {
@card.Header() {
@card.Title() {
Quick Actions
Quick Actions
}
}
@card.Content(card.ContentProps{Class: "space-y-4"}) {
@ -77,14 +80,24 @@ templ SpaceAccountPage(props SpaceAccountPageProps) {
@card.Header() {
<div>
@card.Title() {
Transaction History
Recent Transactions
}
@card.Description() {
Overview of your activity for August 2024
Your latest activity on this account.
}
</div>
}
@card.Content() {
@blocks.TransactionList(props.RecentTransactions)
}
@card.Footer(card.FooterProps{Class: "justify-end"}) {
@button.Button(button.Props{
Variant: button.VariantLink,
Href: routeurl.URL("page.app.spaces.space.accounts.account.transactions", "spaceID", props.SpaceID, "accountID", props.AccountID),
}) {
View all transactions
@icon.ChevronRight()
}
}
}
</div>

View file

@ -0,0 +1,118 @@
package pages
import "fmt"
import "git.juancwu.dev/juancwu/budgit/internal/model"
import "git.juancwu.dev/juancwu/budgit/internal/routeurl"
import "git.juancwu.dev/juancwu/budgit/internal/ui/blocks"
import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/pagination"
type SpaceAccountTransactionsPageProps struct {
SpaceID string
AccountID string
AccountName string
Transactions []*model.Transaction
CurrentPage int
TotalPages int
TotalCount int
PerPage int
}
templ SpaceAccountTransactionsPage(props SpaceAccountTransactionsPageProps) {
@layouts.App("Transactions", spaceOverviewSidebarContent(), spaceSpecificSidebarContent(props.SpaceID)) {
<div class="container px-6 py-8 mx-auto space-y-8">
<div class="flex items-start justify-between flex-wrap gap-4">
<div>
<h1 class="text-3xl font-bold">Transactions</h1>
<p class="text-muted-foreground mt-1">
All activity for { props.AccountName }.
</p>
</div>
<div class="flex gap-2 flex-wrap">
@button.Button(button.Props{
Variant: button.VariantDefault,
Href: routeurl.URL("page.app.spaces.space.accounts.account.bills.create", "spaceID", props.SpaceID, "accountID", props.AccountID),
Class: "flex gap-2 items-center",
}) {
@icon.HandCoins()
Pay Bills
}
@button.Button(button.Props{
Variant: button.VariantSecondary,
Class: "flex gap-2 items-center",
}) {
@icon.BanknoteArrowDown()
Deposit Funds
}
</div>
</div>
@card.Card() {
@card.Header() {
@card.Title() {
All Transactions
}
@card.Description() {
{ transactionsRangeLabel(props) }
}
}
@card.Content() {
@blocks.TransactionList(props.Transactions)
}
if props.TotalPages > 1 {
@card.Footer() {
@transactionsPagination(props)
}
}
}
</div>
}
}
func transactionsRangeLabel(props SpaceAccountTransactionsPageProps) string {
if props.TotalCount == 0 {
return "No transactions yet."
}
start := (props.CurrentPage-1)*props.PerPage + 1
end := start + len(props.Transactions) - 1
return fmt.Sprintf("Showing %d%d of %d", start, end, props.TotalCount)
}
func transactionsPageURL(spaceID, accountID string, page int) string {
base := routeurl.URL("page.app.spaces.space.accounts.account.transactions", "spaceID", spaceID, "accountID", accountID)
return fmt.Sprintf("%s?page=%d", base, page)
}
templ transactionsPagination(props SpaceAccountTransactionsPageProps) {
{{ p := pagination.CreatePagination(props.CurrentPage, props.TotalPages, 5) }}
@pagination.Pagination() {
@pagination.Content() {
@pagination.Item() {
@pagination.Previous(pagination.PreviousProps{
Href: transactionsPageURL(props.SpaceID, props.AccountID, p.CurrentPage-1),
Disabled: !p.HasPrevious,
Label: "Previous",
})
}
for _, page := range p.Pages {
@pagination.Item() {
@pagination.Link(pagination.LinkProps{
Href: transactionsPageURL(props.SpaceID, props.AccountID, page),
IsActive: page == p.CurrentPage,
}) {
{ fmt.Sprintf("%d", page) }
}
}
}
@pagination.Item() {
@pagination.Next(pagination.NextProps{
Href: transactionsPageURL(props.SpaceID, props.AccountID, p.CurrentPage+1),
Disabled: !p.HasNext,
Label: "Next",
})
}
}
}
}