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
|
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) {
|
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")
|
w.Header().Set("HX-Trigger", "transferSuccess")
|
||||||
ui.Render(w, r, moneyaccount.AccountCard(spaceID, &acctWithBalance, true))
|
ui.Render(w, r, moneyaccount.AccountCard(spaceID, &acctWithBalance, true))
|
||||||
ui.Render(w, r, moneyaccount.BalanceSummaryCard(spaceID, totalBalance, newAvailable, 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) {
|
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.AccountCard(spaceID, &acctWithBalance, true))
|
||||||
ui.Render(w, r, moneyaccount.BalanceSummaryCard(spaceID, totalBalance, totalBalance-totalAllocated, 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{
|
ui.RenderToast(w, r, toast.Toast(toast.Props{
|
||||||
Title: "Transfer deleted",
|
Title: "Transfer deleted",
|
||||||
Variant: toast.VariantSuccess,
|
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 ---
|
// --- Recurring Deposits ---
|
||||||
|
|
||||||
func (h *SpaceHandler) getRecurringDepositForSpace(w http.ResponseWriter, spaceID, depositID string) *model.RecurringDeposit {
|
func (h *SpaceHandler) getRecurringDepositForSpace(w http.ResponseWriter, spaceID, depositID string) *model.RecurringDeposit {
|
||||||
|
|
|
||||||
|
|
@ -33,3 +33,8 @@ type MoneyAccountWithBalance struct {
|
||||||
MoneyAccount
|
MoneyAccount
|
||||||
BalanceCents int
|
BalanceCents int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AccountTransferWithAccount struct {
|
||||||
|
AccountTransfer
|
||||||
|
AccountName string `db:"account_name"`
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,9 @@ type MoneyAccountRepository interface {
|
||||||
|
|
||||||
GetAccountBalance(accountID string) (int, error)
|
GetAccountBalance(accountID string) (int, error)
|
||||||
GetTotalAllocatedForSpace(spaceID 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 {
|
type moneyAccountRepository struct {
|
||||||
|
|
@ -135,3 +138,28 @@ func (r *moneyAccountRepository) GetTotalAllocatedForSpace(spaceID string) (int,
|
||||||
err := r.db.Get(&total, query, spaceID)
|
err := r.db.Get(&total, query, spaceID)
|
||||||
return total, err
|
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)
|
mux.HandleFunc("GET /app/spaces/{spaceID}/components/report-charts", reportChartsWithAuth)
|
||||||
|
|
||||||
// Component routes (HTMX updates)
|
// 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)
|
balanceCardHandler := middleware.RequireSpaceAccess(a.SpaceService)(space.GetBalanceCard)
|
||||||
balanceCardWithAuth := middleware.RequireAuth(balanceCardHandler)
|
balanceCardWithAuth := middleware.RequireAuth(balanceCardHandler)
|
||||||
mux.HandleFunc("GET /app/spaces/{spaceID}/components/balance", balanceCardWithAuth)
|
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) {
|
func (s *MoneyAccountService) GetTotalAllocatedForSpace(spaceID string) (int, error) {
|
||||||
return s.accountRepo.GetTotalAllocatedForSpace(spaceID)
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
|
"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/icon"
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
|
"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/label"
|
||||||
|
"git.juancwu.dev/juancwu/budgit/internal/ui/components/pagination"
|
||||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/selectbox"
|
"git.juancwu.dev/juancwu/budgit/internal/ui/components/selectbox"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -538,6 +540,125 @@ templ AddRecurringDepositForm(spaceID string, accounts []model.MoneyAccountWithB
|
||||||
</form>
|
</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) {
|
templ EditRecurringDepositForm(spaceID string, rd *model.RecurringDepositWithAccount, accounts []model.MoneyAccountWithBalance, dialogID string) {
|
||||||
<form
|
<form
|
||||||
hx-patch={ fmt.Sprintf("/app/spaces/%s/accounts/recurring/%s", spaceID, rd.ID) }
|
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"
|
"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) {
|
@layouts.Space("Accounts", space) {
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
|
|
@ -42,6 +42,7 @@ templ SpaceAccountsPage(space *model.Space, accounts []model.MoneyAccountWithBal
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@moneyaccount.RecurringDepositsSection(space.ID, recurringDeposits, accounts)
|
@moneyaccount.RecurringDepositsSection(space.ID, recurringDeposits, accounts)
|
||||||
|
@moneyaccount.TransferHistorySection(space.ID, transfers, currentPage, totalPages)
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue