All checks were successful
Deploy / build-and-deploy (push) Successful in 2m37s
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>
385 lines
12 KiB
Text
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") } · { 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>
|
|
}
|