diff --git a/cmd/server/main.go b/cmd/server/main.go index 7bbcc0a..0a59816 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -51,7 +51,7 @@ func main() { }) srv := &http.Server{ - Addr: ":" + cfg.Port, + Addr: cfg.Host + ":" + cfg.Port, Handler: finalHandler, } diff --git a/internal/handler/space.go b/internal/handler/space.go index dd55e66..f5ed049 100644 --- a/internal/handler/space.go +++ b/internal/handler/space.go @@ -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, })) } diff --git a/internal/repository/transaction.go b/internal/repository/transaction.go index 11c8ab4..e8bdfe6 100644 --- a/internal/repository/transaction.go +++ b/internal/repository/transaction.go @@ -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 +} diff --git a/internal/routes/routes.go b/internal/routes/routes.go index dc7b4c3..ae86ccc 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -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") }) diff --git a/internal/service/transaction.go b/internal/service/transaction.go index f69572b..fd2caab 100644 --- a/internal/service/transaction.go +++ b/internal/service/transaction.go @@ -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 { diff --git a/internal/ui/blocks/transaction_list.templ b/internal/ui/blocks/transaction_list.templ new file mode 100644 index 0000000..ef61397 --- /dev/null +++ b/internal/ui/blocks/transaction_list.templ @@ -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 { +
+ No transactions yet. +
+ } else { + + } +} + +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") + } + }} +
  • +
    +
    + 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"}) + } +
    +
    +

    { t.Title }

    +

    { t.OccurredAt.Format("Jan 2, 2006") }

    +
    +
    +
    +

    + { sign }${ utils.FormatDecimalWithThousands(t.Value.StringFixedBank(2)) } +

    + if t.Description != nil && *t.Description != "" { +

    { *t.Description }

    + } +
    +
  • +} diff --git a/internal/ui/pages/space_account.templ b/internal/ui/pages/space_account.templ index 05189ac..0fa3d6e 100644 --- a/internal/ui/pages/space_account.templ +++ b/internal/ui/pages/space_account.templ @@ -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() {
    @card.Title() { - Transaction History + Recent Transactions } @card.Description() { - Overview of your activity for August 2024 + Your latest activity on this account. }
    } @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() + } } } diff --git a/internal/ui/pages/space_account_transactions.templ b/internal/ui/pages/space_account_transactions.templ new file mode 100644 index 0000000..adcda0f --- /dev/null +++ b/internal/ui/pages/space_account_transactions.templ @@ -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)) { +
    +
    +
    +

    Transactions

    +

    + All activity for { props.AccountName }. +

    +
    +
    + @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 + } +
    +
    + @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) + } + } + } +
    + } +} + +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", + }) + } + } + } +}