budgit/internal/ui/components/moneyaccount/moneyaccount.templ
juancwu 89c5d76e5e
All checks were successful
Deploy / build-and-deploy (push) Successful in 2m37s
Merge branch 'fix/calculation-accuracy' into main
Combines the decimal migration (int cents → decimal.Decimal via
shopspring/decimal) with main's handler refactor (split space.go into
domain handlers, WithTx/Paginate helpers, recurring deposit removal).

- Repository layer: WithTx pattern + decimal column names/types
- Handler layer: decimal arithmetic (.Sub/.Add) instead of int operators
- Models: deprecated amount_cents fields kept for SELECT * compatibility
- INSERT statements: old columns set to literal 0 for NOT NULL constraints

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 16:48:40 -04:00

385 lines
12 KiB
Text

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"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
"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"
"github.com/shopspring/decimal"
)
templ BalanceSummaryCard(spaceID string, totalBalance decimal.Decimal, availableBalance decimal.Decimal, oob bool) {
<div
id="accounts-balance-summary"
class="border rounded-lg p-4 bg-card text-card-foreground"
if oob {
hx-swap-oob="true"
}
>
<h2 class="text-lg font-semibold mb-2">Balance Summary</h2>
<div class="grid grid-cols-3 gap-4">
<div>
<p class="text-sm text-muted-foreground">Total Balance</p>
<p class={ "text-xl font-bold", templ.KV("text-destructive", totalBalance.LessThan(decimal.Zero)) }>
{ model.FormatMoney(totalBalance) }
</p>
</div>
<div>
<p class="text-sm text-muted-foreground">Allocated</p>
<p class="text-xl font-bold">
{ model.FormatMoney(totalBalance.Sub(availableBalance)) }
</p>
</div>
<div>
<p class="text-sm text-muted-foreground">Available</p>
<p class={ "text-xl font-bold", templ.KV("text-destructive", availableBalance.LessThan(decimal.Zero)) }>
{ model.FormatMoney(availableBalance) }
</p>
</div>
</div>
</div>
}
templ AccountCard(spaceID string, acct *model.MoneyAccountWithBalance, oob ...bool) {
{{ editDialogID := "edit-account-" + acct.ID }}
{{ delDialogID := "del-account-" + acct.ID }}
{{ depositDialogID := "deposit-" + acct.ID }}
{{ withdrawDialogID := "withdraw-" + acct.ID }}
<div
id={ "account-card-" + acct.ID }
class="border rounded-lg p-4 bg-card text-card-foreground"
if len(oob) > 0 && oob[0] {
hx-swap-oob="true"
}
>
<div class="flex justify-between items-start mb-3">
<div>
<h3 class="font-semibold text-lg">{ acct.Name }</h3>
<p class={ "text-2xl font-bold", templ.KV("text-destructive", acct.Balance.LessThan(decimal.Zero)) }>
{ model.FormatMoney(acct.Balance) }
</p>
</div>
<div class="flex gap-1">
// Edit
@dialog.Dialog(dialog.Props{ID: editDialogID}) {
@dialog.Trigger() {
@button.Button(button.Props{Variant: button.VariantGhost, Size: button.SizeIcon, Class: "size-7"}) {
@icon.Pencil(icon.Props{Size: 14})
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Edit Account
}
@dialog.Description() {
Update the account name.
}
}
@EditAccountForm(spaceID, &acct.MoneyAccount, editDialogID)
}
}
// Delete
@dialog.Dialog(dialog.Props{ID: delDialogID}) {
@dialog.Trigger() {
@button.Button(button.Props{Variant: button.VariantGhost, Size: button.SizeIcon, Class: "size-7"}) {
@icon.Trash2(icon.Props{Size: 14})
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Delete Account
}
@dialog.Description() {
Are you sure you want to delete "{ acct.Name }"? All transfers will be removed.
}
}
@dialog.Footer() {
@dialog.Close() {
@button.Button(button.Props{Variant: button.VariantOutline}) {
Cancel
}
}
@button.Button(button.Props{
Variant: button.VariantDestructive,
Attributes: templ.Attributes{
"hx-delete": fmt.Sprintf("/app/spaces/%s/accounts/%s", spaceID, acct.ID),
"hx-target": "#account-card-" + acct.ID,
"hx-swap": "delete",
},
}) {
Delete
}
}
}
}
</div>
</div>
<div class="flex gap-2">
// Deposit
@dialog.Dialog(dialog.Props{ID: depositDialogID}) {
@dialog.Trigger() {
@button.Button(button.Props{Variant: button.VariantOutline, Size: button.SizeSm}) {
@icon.ArrowDownToLine(icon.Props{Size: 14})
Deposit
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Deposit to { acct.Name }
}
@dialog.Description() {
Move money from your available balance into this account.
}
}
@TransferForm(spaceID, acct.ID, model.TransferDirectionDeposit, depositDialogID)
}
}
// Withdraw
@dialog.Dialog(dialog.Props{ID: withdrawDialogID}) {
@dialog.Trigger() {
@button.Button(button.Props{Variant: button.VariantOutline, Size: button.SizeSm}) {
@icon.ArrowUpFromLine(icon.Props{Size: 14})
Withdraw
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Withdraw from { acct.Name }
}
@dialog.Description() {
Move money from this account back to your available balance.
}
}
@TransferForm(spaceID, acct.ID, model.TransferDirectionWithdrawal, withdrawDialogID)
}
}
</div>
</div>
}
templ CreateAccountForm(spaceID string, dialogID string) {
<form
hx-post={ "/app/spaces/" + spaceID + "/accounts" }
hx-target="#accounts-list"
hx-swap="beforeend"
_={ "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + dialogID + "') then reset() me end" }
class="space-y-4"
>
@csrf.Token()
<div>
@label.Label(label.Props{For: "account-name"}) {
Account Name
}
@input.Input(input.Props{
Name: "name",
ID: "account-name",
Attributes: templ.Attributes{"required": "true", "placeholder": "e.g. Savings, Emergency Fund"},
})
</div>
<div class="flex justify-end">
@button.Submit() {
Create
}
</div>
</form>
}
templ EditAccountForm(spaceID string, acct *model.MoneyAccount, dialogID string) {
<form
hx-patch={ fmt.Sprintf("/app/spaces/%s/accounts/%s", spaceID, acct.ID) }
hx-target={ "#account-card-" + acct.ID }
hx-swap="outerHTML"
_={ "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + dialogID + "') end" }
class="space-y-4"
>
@csrf.Token()
<div>
@label.Label(label.Props{For: "edit-account-name-" + acct.ID}) {
Account Name
}
@input.Input(input.Props{
Name: "name",
ID: "edit-account-name-" + acct.ID,
Value: acct.Name,
Attributes: templ.Attributes{"required": "true"},
})
</div>
<div class="flex justify-end">
@button.Submit() {
Save
}
</div>
</form>
}
templ TransferForm(spaceID string, accountID string, direction model.TransferDirection, dialogID string) {
{{ errorID := "transfer-error-" + accountID + "-" + string(direction) }}
<form
hx-post={ fmt.Sprintf("/app/spaces/%s/accounts/%s/transfers", spaceID, accountID) }
hx-target={ "#" + errorID }
hx-swap="innerHTML"
_={ "on transferSuccess from body call window.tui.dialog.close('" + dialogID + "') then reset() me end" }
class="space-y-4"
>
@csrf.Token()
<input type="hidden" name="direction" value={ string(direction) }/>
<div>
@label.Label(label.Props{For: "transfer-amount-" + accountID + "-" + string(direction)}) {
Amount
}
@input.Input(input.Props{
Name: "amount",
ID: "transfer-amount-" + accountID + "-" + string(direction),
Type: "number",
Attributes: templ.Attributes{"step": "0.01", "required": "true", "min": "0.01"},
})
<p id={ errorID } class="text-sm text-destructive mt-1"></p>
</div>
<div>
@label.Label(label.Props{For: "transfer-note-" + accountID + "-" + string(direction)}) {
Note (optional)
}
@input.Input(input.Props{
Name: "note",
ID: "transfer-note-" + accountID + "-" + string(direction),
Attributes: templ.Attributes{"placeholder": "e.g. Monthly savings"},
})
</div>
<div class="flex justify-end">
@button.Submit() {
if direction == model.TransferDirectionDeposit {
Deposit
} else {
Withdraw
}
}
</div>
</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>
</div>
<p class="text-sm text-muted-foreground">
{ t.CreatedAt.Format("Jan 2, 2006") } &middot; { 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">
+{ model.FormatMoney(t.Amount) }
</span>
} else {
<span class="font-bold text-destructive whitespace-nowrap">
-{ model.FormatMoney(t.Amount) }
</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>
}