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{
|
||||
Addr: ":" + cfg.Port,
|
||||
Addr: cfg.Host + ":" + cfg.Port,
|
||||
Handler: finalHandler,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
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
|
||||
|
||||
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>
|
||||
|
|
|
|||
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