feat: show recent transactions and view all transactions
This commit is contained in:
parent
d5fc4be7e8
commit
054f1227f0
8 changed files with 313 additions and 12 deletions
|
|
@ -51,7 +51,7 @@ func main() {
|
||||||
})
|
})
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: ":" + cfg.Port,
|
Addr: cfg.Host + ":" + cfg.Port,
|
||||||
Handler: finalHandler,
|
Handler: finalHandler,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package handler
|
||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -193,11 +194,76 @@ func (h *spaceHandler) SpaceAccountPage(w http.ResponseWriter, r *http.Request)
|
||||||
return
|
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{
|
ui.Render(w, r, pages.SpaceAccountPage(pages.SpaceAccountPageProps{
|
||||||
SpaceID: spaceID,
|
SpaceID: spaceID,
|
||||||
AccountID: accountID,
|
AccountID: accountID,
|
||||||
AccountName: account.Name,
|
AccountName: account.Name,
|
||||||
AccountBalance: account.Balance,
|
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,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ import (
|
||||||
|
|
||||||
type TransactionRepository interface {
|
type TransactionRepository interface {
|
||||||
CreateBillAtomic(t *model.Transaction, newBalance decimal.Decimal, categoryID *string) error
|
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 {
|
type transactionRepository struct {
|
||||||
|
|
@ -51,3 +53,26 @@ func (r *transactionRepository) CreateBillAtomic(t *model.Transaction, newBalanc
|
||||||
return nil
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -95,6 +95,7 @@ func SetupRoutes(a *app.App) http.Handler {
|
||||||
|
|
||||||
g.SubGroup("/accounts/{accountID}", func(g *router.Group) {
|
g.SubGroup("/accounts/{accountID}", func(g *router.Group) {
|
||||||
g.Get("/overview", spaceH.SpaceAccountPage).Name("page.app.spaces.space.accounts.account.overview")
|
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.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")
|
g.Post("/bills/create", spaceH.HandleCreateBill).Name("action.app.spaces.space.accounts.account.bills.create")
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -89,6 +89,28 @@ func (s *TransactionService) PayBill(input PayBillInput) (*model.Transaction, er
|
||||||
return txn, nil
|
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) {
|
func (s *TransactionService) ListCategories() ([]*model.Category, error) {
|
||||||
categories, err := s.categoryRepo.All()
|
categories, err := s.categoryRepo.All()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
56
internal/ui/blocks/transaction_list.templ
Normal file
56
internal/ui/blocks/transaction_list.templ
Normal 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>
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
package pages
|
package pages
|
||||||
|
|
||||||
import "github.com/shopspring/decimal"
|
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/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/layouts"
|
||||||
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
|
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
|
||||||
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
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"
|
import "git.juancwu.dev/juancwu/budgit/internal/ui/utils"
|
||||||
|
|
||||||
type SpaceAccountPageProps struct {
|
type SpaceAccountPageProps struct {
|
||||||
SpaceID string
|
SpaceID string
|
||||||
AccountID string
|
AccountID string
|
||||||
AccountName string
|
AccountName string
|
||||||
AccountBalance decimal.Decimal
|
AccountBalance decimal.Decimal
|
||||||
|
RecentTransactions []*model.Transaction
|
||||||
}
|
}
|
||||||
|
|
||||||
templ SpaceAccountPage(props SpaceAccountPageProps) {
|
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.Card(card.Props{Class: "rounded-sm col-span-full md:col-span-4"}) {
|
||||||
@card.Header() {
|
@card.Header() {
|
||||||
@card.Title() {
|
@card.Title() {
|
||||||
Quick Actions
|
Quick Actions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@card.Content(card.ContentProps{Class: "space-y-4"}) {
|
@card.Content(card.ContentProps{Class: "space-y-4"}) {
|
||||||
|
|
@ -77,14 +80,24 @@ templ SpaceAccountPage(props SpaceAccountPageProps) {
|
||||||
@card.Header() {
|
@card.Header() {
|
||||||
<div>
|
<div>
|
||||||
@card.Title() {
|
@card.Title() {
|
||||||
Transaction History
|
Recent Transactions
|
||||||
}
|
}
|
||||||
@card.Description() {
|
@card.Description() {
|
||||||
Overview of your activity for August 2024
|
Your latest activity on this account.
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@card.Content() {
|
@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>
|
</div>
|
||||||
|
|
|
||||||
118
internal/ui/pages/space_account_transactions.templ
Normal file
118
internal/ui/pages/space_account_transactions.templ
Normal 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",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue