feat: show account transfer history
All checks were successful
Deploy / build-and-deploy (push) Successful in 2m50s
All checks were successful
Deploy / build-and-deploy (push) Successful in 2m50s
This commit is contained in:
parent
b34f336aea
commit
e10186fd7a
7 changed files with 223 additions and 2 deletions
|
|
@ -1293,7 +1293,14 @@ func (h *SpaceHandler) AccountsPage(w http.ResponseWriter, r *http.Request) {
|
|||
recurringDeposits = nil
|
||||
}
|
||||
|
||||
ui.Render(w, r, pages.SpaceAccountsPage(space, accounts, totalBalance, availableBalance, recurringDeposits))
|
||||
transfers, totalPages, err := h.accountService.GetTransfersForSpacePaginated(spaceID, 1)
|
||||
if err != nil {
|
||||
slog.Error("failed to get transfers", "error", err, "space_id", spaceID)
|
||||
transfers = nil
|
||||
totalPages = 1
|
||||
}
|
||||
|
||||
ui.Render(w, r, pages.SpaceAccountsPage(space, accounts, totalBalance, availableBalance, recurringDeposits, transfers, 1, totalPages))
|
||||
}
|
||||
|
||||
func (h *SpaceHandler) CreateAccount(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -1502,6 +1509,9 @@ func (h *SpaceHandler) CreateTransfer(w http.ResponseWriter, r *http.Request) {
|
|||
w.Header().Set("HX-Trigger", "transferSuccess")
|
||||
ui.Render(w, r, moneyaccount.AccountCard(spaceID, &acctWithBalance, true))
|
||||
ui.Render(w, r, moneyaccount.BalanceSummaryCard(spaceID, totalBalance, newAvailable, true))
|
||||
|
||||
transfers, transferTotalPages, _ := h.accountService.GetTransfersForSpacePaginated(spaceID, 1)
|
||||
ui.Render(w, r, moneyaccount.TransferHistoryContent(spaceID, transfers, 1, transferTotalPages, true))
|
||||
}
|
||||
|
||||
func (h *SpaceHandler) DeleteTransfer(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -1539,6 +1549,10 @@ func (h *SpaceHandler) DeleteTransfer(w http.ResponseWriter, r *http.Request) {
|
|||
|
||||
ui.Render(w, r, moneyaccount.AccountCard(spaceID, &acctWithBalance, true))
|
||||
ui.Render(w, r, moneyaccount.BalanceSummaryCard(spaceID, totalBalance, totalBalance-totalAllocated, true))
|
||||
|
||||
transfers, transferTotalPages, _ := h.accountService.GetTransfersForSpacePaginated(spaceID, 1)
|
||||
ui.Render(w, r, moneyaccount.TransferHistoryContent(spaceID, transfers, 1, transferTotalPages, true))
|
||||
|
||||
ui.RenderToast(w, r, toast.Toast(toast.Props{
|
||||
Title: "Transfer deleted",
|
||||
Variant: toast.VariantSuccess,
|
||||
|
|
@ -1548,6 +1562,26 @@ func (h *SpaceHandler) DeleteTransfer(w http.ResponseWriter, r *http.Request) {
|
|||
}))
|
||||
}
|
||||
|
||||
// --- Transfer History ---
|
||||
|
||||
func (h *SpaceHandler) GetTransferHistory(w http.ResponseWriter, r *http.Request) {
|
||||
spaceID := r.PathValue("spaceID")
|
||||
|
||||
page := 1
|
||||
if p, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && p > 0 {
|
||||
page = p
|
||||
}
|
||||
|
||||
transfers, totalPages, err := h.accountService.GetTransfersForSpacePaginated(spaceID, page)
|
||||
if err != nil {
|
||||
slog.Error("failed to get transfers", "error", err, "space_id", spaceID)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
ui.Render(w, r, moneyaccount.TransferHistoryContent(spaceID, transfers, page, totalPages, false))
|
||||
}
|
||||
|
||||
// --- Recurring Deposits ---
|
||||
|
||||
func (h *SpaceHandler) getRecurringDepositForSpace(w http.ResponseWriter, spaceID, depositID string) *model.RecurringDeposit {
|
||||
|
|
|
|||
|
|
@ -33,3 +33,8 @@ type MoneyAccountWithBalance struct {
|
|||
MoneyAccount
|
||||
BalanceCents int
|
||||
}
|
||||
|
||||
type AccountTransferWithAccount struct {
|
||||
AccountTransfer
|
||||
AccountName string `db:"account_name"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,9 @@ type MoneyAccountRepository interface {
|
|||
|
||||
GetAccountBalance(accountID string) (int, error)
|
||||
GetTotalAllocatedForSpace(spaceID string) (int, error)
|
||||
|
||||
GetTransfersBySpaceIDPaginated(spaceID string, limit, offset int) ([]*model.AccountTransferWithAccount, error)
|
||||
CountTransfersBySpaceID(spaceID string) (int, error)
|
||||
}
|
||||
|
||||
type moneyAccountRepository struct {
|
||||
|
|
@ -135,3 +138,28 @@ func (r *moneyAccountRepository) GetTotalAllocatedForSpace(spaceID string) (int,
|
|||
err := r.db.Get(&total, query, spaceID)
|
||||
return total, err
|
||||
}
|
||||
|
||||
func (r *moneyAccountRepository) GetTransfersBySpaceIDPaginated(spaceID string, limit, offset int) ([]*model.AccountTransferWithAccount, error) {
|
||||
var transfers []*model.AccountTransferWithAccount
|
||||
query := `SELECT t.id, t.account_id, t.amount_cents, t.direction, t.note,
|
||||
t.recurring_deposit_id, t.created_by, t.created_at, a.name AS account_name
|
||||
FROM account_transfers t
|
||||
JOIN money_accounts a ON t.account_id = a.id
|
||||
WHERE a.space_id = $1
|
||||
ORDER BY t.created_at DESC
|
||||
LIMIT $2 OFFSET $3;`
|
||||
err := r.db.Select(&transfers, query, spaceID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return transfers, nil
|
||||
}
|
||||
|
||||
func (r *moneyAccountRepository) CountTransfersBySpaceID(spaceID string) (int, error) {
|
||||
var count int
|
||||
query := `SELECT COUNT(*) FROM account_transfers t
|
||||
JOIN money_accounts a ON t.account_id = a.id
|
||||
WHERE a.space_id = $1;`
|
||||
err := r.db.Get(&count, query, spaceID)
|
||||
return count, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -239,6 +239,10 @@ func SetupRoutes(a *app.App) http.Handler {
|
|||
mux.HandleFunc("GET /app/spaces/{spaceID}/components/report-charts", reportChartsWithAuth)
|
||||
|
||||
// Component routes (HTMX updates)
|
||||
transferHistoryHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.GetTransferHistory)
|
||||
transferHistoryWithAuth := middleware.RequireAuth(transferHistoryHandler)
|
||||
mux.HandleFunc("GET /app/spaces/{spaceID}/components/transfer-history", transferHistoryWithAuth)
|
||||
|
||||
balanceCardHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.GetBalanceCard)
|
||||
balanceCardWithAuth := middleware.RequireAuth(balanceCardHandler)
|
||||
mux.HandleFunc("GET /app/spaces/{spaceID}/components/balance", balanceCardWithAuth)
|
||||
|
|
|
|||
|
|
@ -171,3 +171,31 @@ func (s *MoneyAccountService) GetAccountBalance(accountID string) (int, error) {
|
|||
func (s *MoneyAccountService) GetTotalAllocatedForSpace(spaceID string) (int, error) {
|
||||
return s.accountRepo.GetTotalAllocatedForSpace(spaceID)
|
||||
}
|
||||
|
||||
const TransfersPerPage = 25
|
||||
|
||||
func (s *MoneyAccountService) GetTransfersForSpacePaginated(spaceID string, page int) ([]*model.AccountTransferWithAccount, int, error) {
|
||||
total, err := s.accountRepo.CountTransfersBySpaceID(spaceID)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
totalPages := (total + TransfersPerPage - 1) / TransfersPerPage
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if page > totalPages {
|
||||
page = totalPages
|
||||
}
|
||||
|
||||
offset := (page - 1) * TransfersPerPage
|
||||
transfers, err := s.accountRepo.GetTransfersBySpaceIDPaginated(spaceID, TransfersPerPage, offset)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return transfers, totalPages, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package moneyaccount
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
|
||||
|
|
@ -10,6 +11,7 @@ import (
|
|||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/label"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/pagination"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/selectbox"
|
||||
)
|
||||
|
||||
|
|
@ -538,6 +540,125 @@ templ AddRecurringDepositForm(spaceID string, accounts []model.MoneyAccountWithB
|
|||
</form>
|
||||
}
|
||||
|
||||
templ TransferHistorySection(spaceID string, transfers []*model.AccountTransferWithAccount, currentPage, totalPages int) {
|
||||
<div class="space-y-4 mt-8">
|
||||
<h2 class="text-xl font-bold">Transfer History</h2>
|
||||
<div class="border rounded-lg">
|
||||
<div id="transfer-history-wrapper">
|
||||
@TransferHistoryContent(spaceID, transfers, currentPage, totalPages, false)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ TransferHistoryContent(spaceID string, transfers []*model.AccountTransferWithAccount, currentPage, totalPages int, oob bool) {
|
||||
<div
|
||||
if oob {
|
||||
id="transfer-history-wrapper"
|
||||
hx-swap-oob="innerHTML:#transfer-history-wrapper"
|
||||
}
|
||||
>
|
||||
<div class="divide-y">
|
||||
if len(transfers) == 0 {
|
||||
<p class="p-4 text-sm text-muted-foreground">No transfers recorded yet.</p>
|
||||
}
|
||||
for _, t := range transfers {
|
||||
@TransferHistoryItem(spaceID, t)
|
||||
}
|
||||
</div>
|
||||
if totalPages > 1 {
|
||||
<div class="border-t p-2">
|
||||
@pagination.Pagination(pagination.Props{Class: "justify-center"}) {
|
||||
@pagination.Content() {
|
||||
@pagination.Item() {
|
||||
@pagination.Previous(pagination.PreviousProps{
|
||||
Disabled: currentPage <= 1,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-get": fmt.Sprintf("/app/spaces/%s/components/transfer-history?page=%d", spaceID, currentPage-1),
|
||||
"hx-target": "#transfer-history-wrapper",
|
||||
"hx-swap": "innerHTML",
|
||||
},
|
||||
})
|
||||
}
|
||||
for _, pg := range pagination.CreatePagination(currentPage, totalPages, 3).Pages {
|
||||
@pagination.Item() {
|
||||
@pagination.Link(pagination.LinkProps{
|
||||
IsActive: pg == currentPage,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-get": fmt.Sprintf("/app/spaces/%s/components/transfer-history?page=%d", spaceID, pg),
|
||||
"hx-target": "#transfer-history-wrapper",
|
||||
"hx-swap": "innerHTML",
|
||||
},
|
||||
}) {
|
||||
{ strconv.Itoa(pg) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@pagination.Item() {
|
||||
@pagination.Next(pagination.NextProps{
|
||||
Disabled: currentPage >= totalPages,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-get": fmt.Sprintf("/app/spaces/%s/components/transfer-history?page=%d", spaceID, currentPage+1),
|
||||
"hx-target": "#transfer-history-wrapper",
|
||||
"hx-swap": "innerHTML",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ TransferHistoryItem(spaceID string, t *model.AccountTransferWithAccount) {
|
||||
<div id={ "transfer-" + t.ID } class="p-4 flex justify-between items-start gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<p class="font-medium">
|
||||
if t.Note != "" {
|
||||
{ t.Note }
|
||||
} else if t.Direction == model.TransferDirectionDeposit {
|
||||
Deposit
|
||||
} else {
|
||||
Withdrawal
|
||||
}
|
||||
</p>
|
||||
if t.RecurringDepositID != nil {
|
||||
@icon.Repeat(icon.Props{Size: 14, Class: "text-muted-foreground shrink-0"})
|
||||
}
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{ t.CreatedAt.Format("Jan 2, 2006") } · { t.AccountName }
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
if t.Direction == model.TransferDirectionDeposit {
|
||||
<span class="font-bold text-green-600 whitespace-nowrap">
|
||||
+{ fmt.Sprintf("$%.2f", float64(t.AmountCents)/100.0) }
|
||||
</span>
|
||||
} else {
|
||||
<span class="font-bold text-destructive whitespace-nowrap">
|
||||
-{ fmt.Sprintf("$%.2f", float64(t.AmountCents)/100.0) }
|
||||
</span>
|
||||
}
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantGhost,
|
||||
Size: button.SizeIcon,
|
||||
Class: "size-7",
|
||||
Attributes: templ.Attributes{
|
||||
"hx-delete": fmt.Sprintf("/app/spaces/%s/accounts/%s/transfers/%s", spaceID, t.AccountID, t.ID),
|
||||
"hx-target": "#transfer-" + t.ID,
|
||||
"hx-swap": "delete",
|
||||
"hx-confirm": "Delete this transfer?",
|
||||
},
|
||||
}) {
|
||||
@icon.Trash2(icon.Props{Size: 14})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ EditRecurringDepositForm(spaceID string, rd *model.RecurringDepositWithAccount, accounts []model.MoneyAccountWithBalance, dialogID string) {
|
||||
<form
|
||||
hx-patch={ fmt.Sprintf("/app/spaces/%s/accounts/recurring/%s", spaceID, rd.ID) }
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import (
|
|||
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
|
||||
)
|
||||
|
||||
templ SpaceAccountsPage(space *model.Space, accounts []model.MoneyAccountWithBalance, totalBalance int, availableBalance int, recurringDeposits []*model.RecurringDepositWithAccount) {
|
||||
templ SpaceAccountsPage(space *model.Space, accounts []model.MoneyAccountWithBalance, totalBalance int, availableBalance int, recurringDeposits []*model.RecurringDepositWithAccount, transfers []*model.AccountTransferWithAccount, currentPage, totalPages int) {
|
||||
@layouts.Space("Accounts", space) {
|
||||
<div class="space-y-4">
|
||||
<div class="flex justify-between items-center">
|
||||
|
|
@ -42,6 +42,7 @@ templ SpaceAccountsPage(space *model.Space, accounts []model.MoneyAccountWithBal
|
|||
}
|
||||
</div>
|
||||
@moneyaccount.RecurringDepositsSection(space.ID, recurringDeposits, accounts)
|
||||
@moneyaccount.TransferHistorySection(space.ID, transfers, currentPage, totalPages)
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue