feat: add currency to accounts

This commit is contained in:
juancwu 2026-05-04 04:24:08 +00:00
commit ca0fec563e
21 changed files with 627 additions and 63 deletions

View file

@ -6,10 +6,11 @@ import "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
import "git.juancwu.dev/juancwu/budgit/internal/routeurl"
type AccountCardInfo struct {
SpaceID string
ID string
Name string
Balance decimal.Decimal
SpaceID string
ID string
Name string
Balance decimal.Decimal
Currency string
}
templ AccountCard(info AccountCardInfo) {
@ -22,7 +23,7 @@ templ AccountCard(info AccountCardInfo) {
<div>
<p class="font-semibold">{ info.Name }</p>
<p class="text-xs text-muted-foreground">
${ info.Balance.StringFixedBank(2) }
${ info.Balance.StringFixedBank(2) } { info.Currency }
</p>
</div>
</div>

View file

@ -0,0 +1,117 @@
package forms
import "git.juancwu.dev/juancwu/budgit/internal/misc/currency"
import "git.juancwu.dev/juancwu/budgit/internal/routeurl"
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/form"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
type ChangeAccountCurrencyProps struct {
SpaceID string
AccountID string
CurrentCurrency string
NewCurrency string
ConversionRate string
NewCurrencyErr string
RateErr string
GeneralErr string
SuccessMsg string
}
templ ChangeAccountCurrency(props ChangeAccountCurrencyProps) {
<form
id="change-currency-form"
hx-post={ routeurl.URL("action.app.spaces.space.accounts.account.settings.currency", "spaceID", props.SpaceID, "accountID", props.AccountID) }
hx-swap="outerHTML"
>
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Header() {
@card.Title() {
Currency
}
@card.Description() {
Currently { props.CurrentCurrency }. Changing the currency converts the balance and every allocation at the rate you provide.
}
}
@card.Content(card.ContentProps{Class: "space-y-4"}) {
if props.GeneralErr != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ props.GeneralErr }
}
}
if props.SuccessMsg != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantInfo}) {
{ props.SuccessMsg }
}
}
@form.Item() {
@form.Label(form.LabelProps{For: "new_currency"}) {
New currency
}
{{ selected := props.NewCurrency }}
<select
id="new_currency"
name="new_currency"
class={ "flex h-9 w-full items-center rounded-sm border bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
templ.KV("border-destructive", props.NewCurrencyErr != ""),
templ.KV("border-input", props.NewCurrencyErr == "") }
required
>
<option value="" selected?={ selected == "" }>Select a currency…</option>
for _, code := range currency.Supported() {
if code != props.CurrentCurrency {
<option value={ code } selected?={ selected == code }>{ code }</option>
}
}
</select>
if props.NewCurrencyErr != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ props.NewCurrencyErr }
}
}
}
@form.Item() {
@form.Label(form.LabelProps{For: "rate"}) {
Conversion rate
}
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">1 { props.CurrentCurrency } =</span>
@input.Input(input.Props{
ID: "rate",
Name: "rate",
Type: input.TypeNumber,
Placeholder: "1.000000",
Class: "rounded-sm",
Value: props.ConversionRate,
HasError: props.RateErr != "",
Required: true,
Attributes: templ.Attributes{
"step": "0.000001",
"min": "0",
"inputmode": "decimal",
"autocomplete": "off",
},
})
<span class="text-sm text-muted-foreground">new currency</span>
</div>
if props.RateErr != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ props.RateErr }
}
}
@form.Description() {
Balance and allocation amounts are multiplied by this rate and rounded to 2 decimals.
}
}
}
@card.Footer(card.FooterProps{Class: "flex justify-end gap-2"}) {
@button.Button(button.Props{Type: button.TypeSubmit}) {
Change currency
}
}
}
</form>
}

View file

@ -1,5 +1,6 @@
package forms
import "git.juancwu.dev/juancwu/budgit/internal/misc/currency"
import "git.juancwu.dev/juancwu/budgit/internal/routeurl"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
@ -9,10 +10,12 @@ import "git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
type CreateAccountProps struct {
SpaceID string
Name string
Name string
Currency string
NameErr string
GeneralErr string
NameErr string
CurrencyErr string
GeneralErr string
}
templ CreateAccount(props CreateAccountProps) {
@ -48,7 +51,35 @@ templ CreateAccount(props CreateAccountProps) {
}
}
@form.Description() {
You can rename the account later. Starts with a $0.00 balance.
You can rename the account later. Starts with a 0.00 balance.
}
}
{{
selected := props.Currency
if selected == "" {
selected = currency.Default
}
}}
@form.Item() {
@form.Label(form.LabelProps{For: "currency"}) {
Currency
}
<select
id="currency"
name="currency"
class={ "flex h-9 w-full items-center rounded-sm border bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
templ.KV("border-destructive", props.CurrencyErr != ""),
templ.KV("border-input", props.CurrencyErr == "") }
required
>
for _, code := range currency.Supported() {
<option value={ code } selected?={ selected == code }>{ code }</option>
}
</select>
if props.CurrencyErr != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ props.CurrencyErr }
}
}
}
}

View file

@ -12,6 +12,7 @@ import "git.juancwu.dev/juancwu/budgit/internal/ui/utils"
type CreateTransferProps struct {
SpaceID string
SourceAccountID string
SourceCurrency string
// DestAccounts is the list of other accounts in the same space the user
// can transfer to. Excludes the source account.
@ -19,19 +20,21 @@ type CreateTransferProps struct {
// SourceAvailable / SourceAllocated are the source account's unallocated
// and allocated cash, formatted as plain decimal strings (e.g. "1234.50").
SourceAvailable string
SourceAllocated string
SourceOverflow bool
SourceAvailable string
SourceAllocated string
SourceOverflow bool
Title string
Amount string
DestAccountID string
Date string
Description string
Title string
Amount string
DestAccountID string
ConversionRate string
Date string
Description string
TitleErr string
AmountErr string
DestErr string
RateErr string
DateErr string
GeneralErr string
}
@ -104,10 +107,26 @@ templ CreateTransfer(props CreateTransferProps) {
templ.KV("border-destructive", props.DestErr != ""),
templ.KV("border-input", props.DestErr == "") }
required
data-source-currency={ props.SourceCurrency }
_="on change
set opt to my.options[my.selectedIndex]
set destCur to opt.getAttribute('data-currency') or ''
set srcCur to @data-source-currency of me
if destCur is not '' and destCur is not srcCur
remove .hidden from #rate-row
set #rate-dest-currency.innerText to destCur
set #rate-source-currency.innerText to srcCur
else
add .hidden to #rate-row
end"
>
<option value="" selected?={ props.DestAccountID == "" }>Select an account…</option>
for _, a := range props.DestAccounts {
<option value={ a.ID } selected?={ props.DestAccountID == a.ID }>{ a.Name }</option>
<option
value={ a.ID }
data-currency={ a.Currency }
selected?={ props.DestAccountID == a.ID }
>{ a.Name } ({ a.Currency })</option>
}
</select>
}
@ -167,6 +186,57 @@ templ CreateTransfer(props CreateTransferProps) {
}
}
</div>
{{
selectedDestCurrency := ""
for _, a := range props.DestAccounts {
if a.ID == props.DestAccountID {
selectedDestCurrency = a.Currency
break
}
}
rateRowHidden := selectedDestCurrency == "" || selectedDestCurrency == props.SourceCurrency
rateRowClasses := []string{"space-y-2 rounded-md border p-4 bg-muted/30"}
if rateRowHidden {
rateRowClasses = append(rateRowClasses, "hidden")
}
}}
<div id="rate-row" class={ utils.TwMerge(rateRowClasses...) }>
<p class="text-sm">
Source and destination use different currencies. Set the conversion rate.
</p>
@form.Item() {
@form.Label(form.LabelProps{For: "rate"}) {
Conversion rate
}
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">1 <span id="rate-source-currency">{ props.SourceCurrency }</span> =</span>
@input.Input(input.Props{
ID: "rate",
Name: "rate",
Type: input.TypeNumber,
Placeholder: "1.00",
Class: "rounded-sm",
Value: props.ConversionRate,
HasError: props.RateErr != "",
Attributes: templ.Attributes{
"step": "0.000001",
"min": "0",
"inputmode": "decimal",
"autocomplete": "off",
},
})
<span class="text-sm text-muted-foreground" id="rate-dest-currency">{ selectedDestCurrency }</span>
</div>
if props.RateErr != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ props.RateErr }
}
}
@form.Description() {
The destination account will be credited the converted amount, rounded to 2 decimals.
}
}
</div>
@form.Item() {
@form.Label(form.LabelProps{For: "description"}) {
Description

View file

@ -6,20 +6,22 @@ import "git.juancwu.dev/juancwu/budgit/internal/routeurl"
import "git.juancwu.dev/juancwu/budgit/internal/service"
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/badge"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
import "git.juancwu.dev/juancwu/budgit/internal/ui/utils"
type SpaceAccountPageProps struct {
SpaceID string
SpaceName string
AccountID string
AccountName string
AccountBalance decimal.Decimal
RecentTransactions []*model.Transaction
SpaceID string
SpaceName string
AccountID string
AccountName string
AccountBalance decimal.Decimal
AccountCurrency string
RecentTransactions []*model.Transaction
NonEditableTransactionIDs map[string]bool
AllocationSummary *service.AllocationSummary
AllocationSummary *service.AllocationSummary
}
templ SpaceAccountPage(props SpaceAccountPageProps) {
@ -39,12 +41,20 @@ templ SpaceAccountPage(props SpaceAccountPageProps) {
}) {
@card.Header() {
@card.Title() {
{ props.AccountName }
<div class="flex items-center gap-3">
<span>{ props.AccountName }</span>
@badge.Badge(badge.Props{Variant: badge.VariantSecondary, Class: "text-xs font-medium"}) {
{ props.AccountCurrency }
}
</div>
}
}
@card.Content() {
<h1 class={ utils.TwMerge(balanceTextClasses...) }>${ utils.FormatDecimalWithThousands(props.AccountBalance.StringFixedBank(2)) }</h1>
<p class="text-sm text-muted-foreground">Account Balance</p>
<h1 class={ utils.TwMerge(balanceTextClasses...) }>
${ utils.FormatDecimalWithThousands(props.AccountBalance.StringFixedBank(2)) }
<span class="text-xl font-semibold text-muted-foreground ml-2">{ props.AccountCurrency }</span>
</h1>
<p class="text-sm text-muted-foreground">Account Balance ({ props.AccountCurrency })</p>
}
}
@card.Card(card.Props{Class: "rounded-sm col-span-full md:col-span-4"}) {

View file

@ -9,11 +9,13 @@ import "git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
type SpaceAccountSettingsPageProps struct {
SpaceID string
SpaceName string
AccountID string
AccountName string
UpdateForm forms.UpdateAccountProps
SpaceID string
SpaceName string
AccountID string
AccountName string
AccountCurrency string
UpdateForm forms.UpdateAccountProps
CurrencyForm forms.ChangeAccountCurrencyProps
}
templ SpaceAccountSettingsPage(props SpaceAccountSettingsPageProps) {
@ -32,6 +34,7 @@ templ SpaceAccountSettingsPage(props SpaceAccountSettingsPageProps) {
</p>
</div>
@forms.UpdateAccount(props.UpdateForm)
@forms.ChangeAccountCurrency(props.CurrencyForm)
@card.Card(card.Props{Class: "rounded-sm border-destructive"}) {
@card.Header() {
@card.Title(card.TitleProps{Class: "text-destructive"}) {