feat: investment accounts
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m50s

This commit is contained in:
juancwu 2026-05-22 14:49:57 +00:00
commit 7c24a8302d
25 changed files with 2205 additions and 56 deletions

View file

@ -0,0 +1,220 @@
package blocks
import (
"fmt"
"strings"
"github.com/shopspring/decimal"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/routeurl"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
"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/utils"
)
type InvestmentSectionProps struct {
SpaceID string
AccountID string
Currency string
Summary *model.InvestmentAccountSummary
Positions []model.HoldingPosition
}
func fmtMoney(d decimal.Decimal) string {
s, _ := utils.FormatDecimalWithThousands(d.StringFixedBank(2))
return s
}
func subtypeLabel(s *string) string {
if s == nil {
return ""
}
return strings.ToUpper(*s)
}
func plClass(d decimal.Decimal) string {
if d.IsNegative() {
return "text-red-600 dark:text-red-400"
}
if d.IsPositive() {
return "text-green-600 dark:text-green-400"
}
return ""
}
templ InvestmentSection(props InvestmentSectionProps) {
<div id="investment-section" class="space-y-4">
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Header() {
<div class="flex items-start justify-between gap-3">
<div>
@card.Title() {
<div class="flex items-center gap-2">
Contribution Room ({ fmt.Sprintf("%d", props.Summary.Year) })
if label := subtypeLabel(props.Summary.Account.InvestmentSubtype); label != "" {
@badge.Badge(badge.Props{Variant: badge.VariantSecondary, Class: "text-xs"}) {
{ label }
}
}
</div>
}
@card.Description() {
Track yearly contributions against the room you set.
}
</div>
</div>
}
@card.Content(card.ContentProps{Class: "space-y-4"}) {
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<div class="text-muted-foreground">Room</div>
<div class="text-lg font-semibold">
if props.Summary.RoomAmount != nil {
${ fmtMoney(*props.Summary.RoomAmount) }
} else {
<span class="text-muted-foreground italic">Not set</span>
}
</div>
</div>
<div>
<div class="text-muted-foreground">YTD Contributions</div>
<div class="text-lg font-semibold">${ fmtMoney(props.Summary.YTDContributions) }</div>
</div>
<div>
<div class="text-muted-foreground">YTD Withdrawals</div>
<div class="text-lg font-semibold">${ fmtMoney(props.Summary.YTDWithdrawals) }</div>
</div>
<div>
<div class="text-muted-foreground">Remaining</div>
<div class={ "text-lg font-semibold", plClass(remainingValue(props.Summary)) }>
if props.Summary.RoomRemaining != nil {
${ fmtMoney(*props.Summary.RoomRemaining) }
} else {
<span class="text-muted-foreground">—</span>
}
</div>
</div>
</div>
<form
hx-post={ routeurl.URL("action.app.spaces.space.accounts.account.investments.contribution-room", "spaceID", props.SpaceID, "accountID", props.AccountID) }
hx-target="#investment-section"
hx-swap="outerHTML"
class="flex flex-wrap items-end gap-2"
>
<div class="flex flex-col gap-1">
<label class="text-xs text-muted-foreground" for="room-year">Year</label>
@input.Input(input.Props{
ID: "room-year",
Name: "year",
Type: input.TypeNumber,
Value: fmt.Sprintf("%d", props.Summary.Year),
Class: "w-28 rounded-sm",
})
</div>
<div class="flex flex-col gap-1">
<label class="text-xs text-muted-foreground" for="room-amount">Room amount ({ props.Currency })</label>
{{
currentRoom := ""
if props.Summary.RoomAmount != nil {
currentRoom = props.Summary.RoomAmount.StringFixedBank(2)
}
}}
@input.Input(input.Props{
ID: "room-amount",
Name: "room",
Type: input.TypeText,
Value: currentRoom,
Placeholder: "0.00",
Class: "w-40 rounded-sm",
Required: true,
})
</div>
@button.Button(button.Props{Type: button.TypeSubmit, Class: "rounded-sm"}) {
Save room
}
</form>
}
}
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Header() {
<div class="flex items-center justify-between">
<div>
@card.Title() {
Holdings
}
@card.Description() {
Track shares and buy/sell prices. Cost basis is computed from your trades.
}
</div>
@button.Button(button.Props{
Variant: button.VariantSecondary,
Class: "flex gap-2 items-center rounded-sm",
Href: routeurl.URL("page.app.spaces.space.accounts.account.investments.holdings.create", "spaceID", props.SpaceID, "accountID", props.AccountID),
}) {
@icon.Plus()
Add holding
}
</div>
}
@card.Content() {
if len(props.Positions) == 0 {
<p class="text-sm text-muted-foreground">No holdings yet. Add one to start tracking shares and trades.</p>
} else {
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-left text-muted-foreground border-b">
<tr>
<th class="py-2 pr-2">Symbol</th>
<th class="py-2 pr-2">Quantity</th>
<th class="py-2 pr-2">Avg cost</th>
<th class="py-2 pr-2">Cost basis</th>
<th class="py-2 pr-2">Realized P/L</th>
<th class="py-2"></th>
</tr>
</thead>
<tbody>
for _, pos := range props.Positions {
<tr class="border-b last:border-b-0">
<td class="py-2 pr-2">
<a
class="font-medium hover:underline"
href={ templ.SafeURL(routeurl.URL("page.app.spaces.space.accounts.account.investments.holdings.holding", "spaceID", props.SpaceID, "accountID", props.AccountID, "holdingID", pos.Holding.ID)) }
>
{ pos.Holding.Symbol }
</a>
<div class="text-xs text-muted-foreground">{ pos.Holding.DisplayName }</div>
</td>
<td class="py-2 pr-2">{ pos.Quantity.StringFixedBank(4) }</td>
<td class="py-2 pr-2">${ fmtMoney(pos.AvgCost) }</td>
<td class="py-2 pr-2">${ fmtMoney(pos.CostBasis) }</td>
<td class={ "py-2 pr-2", plClass(pos.RealizedPL) }>${ fmtMoney(pos.RealizedPL) }</td>
<td class="py-2 text-right">
@button.Button(button.Props{
Variant: button.VariantGhost,
Class: "h-8 px-2",
Href: routeurl.URL("page.app.spaces.space.accounts.account.investments.holdings.holding", "spaceID", props.SpaceID, "accountID", props.AccountID, "holdingID", pos.Holding.ID),
}) {
@icon.ChevronRight()
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
}
}
</div>
}
func remainingValue(s *model.InvestmentAccountSummary) decimal.Decimal {
if s.RoomRemaining == nil {
return decimal.Zero
}
return *s.RoomRemaining
}

View file

@ -10,14 +10,28 @@ import "git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
type CreateAccountProps struct {
SpaceID string
Name string
Currency string
Name string
Currency string
IsInvestment bool
InvestmentSubtype string
NameErr string
CurrencyErr string
SubtypeErr string
GeneralErr string
}
var investmentSubtypes = []struct {
Value string
Label string
}{
{"tfsa", "TFSA"},
{"rrsp", "RRSP"},
{"fhsa", "FHSA"},
{"personal", "Personal / Non-registered"},
{"other", "Other"},
}
templ CreateAccount(props CreateAccountProps) {
<form hx-post={ routeurl.URL("action.app.spaces.space.accounts.create", "spaceID", props.SpaceID) }>
@card.Card(card.Props{Class: "rounded-sm"}) {
@ -82,6 +96,54 @@ templ CreateAccount(props CreateAccountProps) {
}
}
}
{{
selectedSubtype := props.InvestmentSubtype
if selectedSubtype == "" {
selectedSubtype = "tfsa"
}
}}
@form.Item() {
<label class="flex items-center gap-2 text-sm font-medium">
<input
type="checkbox"
name="is_investment"
value="1"
class="h-4 w-4 rounded border-input"
checked?={ props.IsInvestment }
_="on change toggle .hidden on #investment-subtype-wrapper"
/>
Investment account
</label>
@form.Description() {
Tracks contributions, contribution room, and holdings.
}
}
<div
id="investment-subtype-wrapper"
class={ templ.KV("hidden", !props.IsInvestment) }
>
@form.Item() {
@form.Label(form.LabelProps{For: "investment_subtype"}) {
Account type
}
<select
id="investment_subtype"
name="investment_subtype"
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.SubtypeErr != ""),
templ.KV("border-input", props.SubtypeErr == "") }
>
for _, opt := range investmentSubtypes {
<option value={ opt.Value } selected?={ selectedSubtype == opt.Value }>{ opt.Label }</option>
}
</select>
if props.SubtypeErr != "" {
@form.Message(form.MessageProps{Variant: form.MessageVariantError}) {
{ props.SubtypeErr }
}
}
}
</div>
}
@card.Footer(card.FooterProps{Class: "flex justify-end gap-2"}) {
@button.Button(button.Props{

View file

@ -0,0 +1,213 @@
package pages
import (
"fmt"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/routeurl"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/form"
"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/layouts"
"git.juancwu.dev/juancwu/budgit/internal/ui/utils"
)
type InvestmentHoldingDetailProps struct {
SpaceID string
SpaceName string
AccountID string
AccountName string
Currency string
Position model.HoldingPosition
Trades []*model.InvestmentTrade
}
func holdingPlClass(d string) string {
if len(d) > 0 && d[0] == '-' {
return "text-red-600 dark:text-red-400"
}
return "text-green-600 dark:text-green-400"
}
templ InvestmentHoldingDetailPage(props InvestmentHoldingDetailProps) {
@layouts.AppWithBreadcrumb(
props.Position.Holding.Symbol,
accountChildBreadcrumb(props.SpaceID, props.SpaceName, props.AccountID, props.AccountName, props.Position.Holding.Symbol),
spaceOverviewSidebarContent(),
spaceSpecificSidebarContent(props.SpaceID),
spaceAccountSidebarContent(props.SpaceID, props.AccountID),
) {
<div class="container px-6 py-8 mx-auto space-y-8">
<div class="flex items-start justify-between gap-3 flex-wrap">
<div>
<h1 class="text-3xl font-bold flex items-center gap-3">
{ props.Position.Holding.Symbol }
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
{ props.Currency }
}
</h1>
<p class="text-muted-foreground">{ props.Position.Holding.DisplayName }</p>
</div>
<form
hx-post={ routeurl.URL("action.app.spaces.space.accounts.account.investments.holdings.holding.delete", "spaceID", props.SpaceID, "accountID", props.AccountID, "holdingID", props.Position.Holding.ID) }
hx-confirm="Delete this holding and all its trades?"
>
@button.Button(button.Props{
Type: button.TypeSubmit,
Variant: button.VariantDestructive,
Class: "flex items-center gap-2",
}) {
@icon.Trash2()
Delete holding
}
</form>
</div>
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Content(card.ContentProps{Class: "grid grid-cols-2 md:grid-cols-5 gap-4 text-sm p-4"}) {
<div>
<div class="text-muted-foreground">Quantity</div>
<div class="text-lg font-semibold">{ props.Position.Quantity.StringFixedBank(4) }</div>
</div>
<div>
<div class="text-muted-foreground">Avg cost</div>
<div class="text-lg font-semibold">${ utils.FormatDecimalWithThousands(props.Position.AvgCost.StringFixedBank(2)) }</div>
</div>
<div>
<div class="text-muted-foreground">Cost basis</div>
<div class="text-lg font-semibold">${ utils.FormatDecimalWithThousands(props.Position.CostBasis.StringFixedBank(2)) }</div>
</div>
<div>
<div class="text-muted-foreground">Realized P/L</div>
<div class={ "text-lg font-semibold", holdingPlClass(props.Position.RealizedPL.StringFixedBank(2)) }>
${ utils.FormatDecimalWithThousands(props.Position.RealizedPL.StringFixedBank(2)) }
</div>
</div>
<div>
<div class="text-muted-foreground">Total fees</div>
<div class="text-lg font-semibold">${ utils.FormatDecimalWithThousands(props.Position.TotalFees.StringFixedBank(2)) }</div>
</div>
}
}
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Header() {
@card.Title() {
Record a trade
}
@card.Description() {
Manually enter a buy or sell. Quantities and prices recompute the position.
}
}
@card.Content() {
<form
hx-post={ routeurl.URL("action.app.spaces.space.accounts.account.investments.holdings.holding.trades.create", "spaceID", props.SpaceID, "accountID", props.AccountID, "holdingID", props.Position.Holding.ID) }
class="grid grid-cols-1 md:grid-cols-6 gap-3 items-end"
>
@form.Item() {
@form.Label(form.LabelProps{For: "type"}) { Type }
<select id="type" name="type" class="h-9 rounded-sm border bg-transparent px-3 text-sm">
<option value="buy">Buy</option>
<option value="sell">Sell</option>
</select>
}
@form.Item() {
@form.Label(form.LabelProps{For: "quantity"}) { Quantity }
@input.Input(input.Props{ID: "quantity", Name: "quantity", Type: input.TypeText, Placeholder: "0", Class: "rounded-sm", Required: true})
}
@form.Item() {
@form.Label(form.LabelProps{For: "price"}) { Price / unit }
@input.Input(input.Props{ID: "price", Name: "price", Type: input.TypeText, Placeholder: "0.00", Class: "rounded-sm", Required: true})
}
@form.Item() {
@form.Label(form.LabelProps{For: "fees"}) { Fees }
@input.Input(input.Props{ID: "fees", Name: "fees", Type: input.TypeText, Placeholder: "0.00", Class: "rounded-sm"})
}
@form.Item() {
@form.Label(form.LabelProps{For: "occurred_at"}) { Date }
@input.Input(input.Props{ID: "occurred_at", Name: "occurred_at", Type: input.TypeDate, Value: time.Now().Format("2006-01-02"), Class: "rounded-sm"})
}
@button.Button(button.Props{Type: button.TypeSubmit, Class: "rounded-sm"}) {
Record
}
</form>
}
}
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Header() {
@card.Title() { Trade history }
}
@card.Content() {
if len(props.Trades) == 0 {
<p class="text-sm text-muted-foreground">No trades recorded yet.</p>
} else {
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-left text-muted-foreground border-b">
<tr>
<th class="py-2 pr-2">Date</th>
<th class="py-2 pr-2">Type</th>
<th class="py-2 pr-2">Quantity</th>
<th class="py-2 pr-2">Price / unit</th>
<th class="py-2 pr-2">Fees</th>
<th class="py-2 pr-2">Total</th>
<th class="py-2"></th>
</tr>
</thead>
<tbody>
for _, t := range props.Trades {
{{
fees := "—"
if t.Fees != nil {
fees = t.Fees.StringFixedBank(2)
}
total := t.Quantity.Mul(t.PricePerUnit)
typeLabel := "Buy"
if string(t.Type) == "sell" {
typeLabel = "Sell"
}
}}
<tr class="border-b last:border-b-0">
<td class="py-2 pr-2">{ t.OccurredAt.Format("2006-01-02") }</td>
<td class="py-2 pr-2">
@badge.Badge(badge.Props{
Variant: badge.VariantSecondary,
Class: "text-xs",
}) {
{ typeLabel }
}
</td>
<td class="py-2 pr-2">{ t.Quantity.StringFixedBank(4) }</td>
<td class="py-2 pr-2">${ utils.FormatDecimalWithThousands(t.PricePerUnit.StringFixedBank(2)) }</td>
<td class="py-2 pr-2">{ fees }</td>
<td class="py-2 pr-2">${ utils.FormatDecimalWithThousands(total.StringFixedBank(2)) }</td>
<td class="py-2 text-right">
<form
hx-post={ routeurl.URL("action.app.spaces.space.accounts.account.investments.holdings.holding.trades.trade.delete", "spaceID", props.SpaceID, "accountID", props.AccountID, "holdingID", props.Position.Holding.ID, "tradeID", t.ID) }
hx-confirm="Delete this trade?"
class="inline"
>
@button.Button(button.Props{
Type: button.TypeSubmit,
Variant: button.VariantGhost,
Class: "h-8 px-2",
}) {
@icon.Trash2()
}
</form>
</td>
</tr>
}
</tbody>
</table>
</div>
}
}
}
</div>
}
}
var _ = fmt.Sprintf

View file

@ -0,0 +1,88 @@
package pages
import (
"git.juancwu.dev/juancwu/budgit/internal/routeurl"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/form"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
type InvestmentHoldingFormPageProps struct {
SpaceID string
SpaceName string
AccountID string
AccountName string
}
templ InvestmentHoldingFormPage(props InvestmentHoldingFormPageProps) {
@layouts.AppWithBreadcrumb(
"Add holding",
accountChildBreadcrumb(props.SpaceID, props.SpaceName, props.AccountID, props.AccountName, "Add holding"),
spaceOverviewSidebarContent(),
spaceSpecificSidebarContent(props.SpaceID),
spaceAccountSidebarContent(props.SpaceID, props.AccountID),
) {
<div class="container max-w-3xl px-6 py-8 mx-auto space-y-8">
<div>
<h1 class="text-3xl font-bold">Add holding</h1>
<p class="text-muted-foreground mt-2">
Track a symbol inside { props.AccountName }. You can record buy/sell trades after creating it.
</p>
</div>
<form hx-post={ routeurl.URL("action.app.spaces.space.accounts.account.investments.holdings.create", "spaceID", props.SpaceID, "accountID", props.AccountID) }>
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Content(card.ContentProps{Class: "p-4 space-y-4"}) {
@form.Item() {
@form.Label(form.LabelProps{For: "symbol"}) {
Symbol
}
@input.Input(input.Props{
ID: "symbol",
Name: "symbol",
Type: input.TypeText,
Placeholder: "e.g. VFV.TO",
Class: "rounded-sm uppercase",
Required: true,
Attributes: templ.Attributes{
"autocomplete": "off",
"autofocus": "",
},
})
@form.Description() {
Use the broker's symbol exactly (e.g. VFV.TO for TSX, AAPL for NASDAQ).
}
}
@form.Item() {
@form.Label(form.LabelProps{For: "display_name"}) {
Display name
}
@input.Input(input.Props{
ID: "display_name",
Name: "display_name",
Type: input.TypeText,
Placeholder: "e.g. Vanguard S&P 500 Index ETF",
Class: "rounded-sm",
})
@form.Description() {
Optional. Falls back to the symbol if left empty.
}
}
}
@card.Footer(card.FooterProps{Class: "flex justify-end gap-2"}) {
@button.Button(button.Props{
Variant: button.VariantGhost,
Href: routeurl.URL("page.app.spaces.space.accounts.account.overview", "spaceID", props.SpaceID, "accountID", props.AccountID),
}) {
Cancel
}
@button.Button(button.Props{Type: button.TypeSubmit}) {
Add holding
}
}
}
</form>
</div>
}
}

View file

@ -0,0 +1,131 @@
package pages
import (
"fmt"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/routeurl"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/card"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
"git.juancwu.dev/juancwu/budgit/internal/ui/utils"
)
type InvestmentOverviewRow struct {
SpaceID string
SpaceName string
AccountID string
AccountName string
Currency string
Summary *model.InvestmentAccountSummary
}
type InvestmentsOverviewProps struct {
Year int
Rows []InvestmentOverviewRow
}
templ InvestmentsOverviewPage(props InvestmentsOverviewProps) {
@layouts.App("Investments", spaceOverviewSidebarContent()) {
<div class="container px-6 py-8 mx-auto space-y-6">
<div class="flex items-start justify-between gap-3 flex-wrap">
<div>
<h1 class="text-3xl font-bold">Investments</h1>
<p class="text-muted-foreground mt-1">
{ fmt.Sprintf("%d contribution rooms, YTD activity, and total cost basis across your investment accounts.", props.Year) }
</p>
</div>
</div>
if len(props.Rows) == 0 {
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Content(card.ContentProps{Class: "p-6 space-y-3"}) {
<p class="text-sm text-muted-foreground">
You don't have any investment accounts yet. In any space, create a new account and check
<strong>Investment account</strong> to start tracking contributions and holdings.
</p>
}
}
} else {
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
for _, row := range props.Rows {
{{
subtypeLabel := ""
if row.Summary != nil && row.Summary.Account != nil && row.Summary.Account.InvestmentSubtype != nil {
subtypeLabel = upperString(*row.Summary.Account.InvestmentSubtype)
}
}}
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Header() {
<div class="flex items-start justify-between gap-2">
<div>
@card.Title() {
<div class="flex items-center gap-2">
{ row.AccountName }
if subtypeLabel != "" {
@badge.Badge(badge.Props{Variant: badge.VariantSecondary, Class: "text-xs"}) {
{ subtypeLabel }
}
}
@badge.Badge(badge.Props{Variant: badge.VariantOutline, Class: "text-xs"}) {
{ row.Currency }
}
</div>
}
@card.Description() {
{ row.SpaceName }
}
</div>
@button.Button(button.Props{
Variant: button.VariantGhost,
Class: "h-8 px-2",
Href: routeurl.URL("page.app.spaces.space.accounts.account.overview", "spaceID", row.SpaceID, "accountID", row.AccountID),
}) {
@icon.ChevronRight()
}
</div>
}
@card.Content(card.ContentProps{Class: "grid grid-cols-2 gap-3 text-sm"}) {
<div>
<div class="text-muted-foreground">YTD Contributions</div>
<div class="font-semibold">${ utils.FormatDecimalWithThousands(row.Summary.YTDContributions.StringFixedBank(2)) }</div>
</div>
<div>
<div class="text-muted-foreground">YTD Withdrawals</div>
<div class="font-semibold">${ utils.FormatDecimalWithThousands(row.Summary.YTDWithdrawals.StringFixedBank(2)) }</div>
</div>
<div>
<div class="text-muted-foreground">Room remaining</div>
<div class="font-semibold">
if row.Summary.RoomRemaining != nil {
${ utils.FormatDecimalWithThousands(row.Summary.RoomRemaining.StringFixedBank(2)) }
} else {
<span class="text-muted-foreground italic">Room not set</span>
}
</div>
</div>
<div>
<div class="text-muted-foreground">Total cost basis</div>
<div class="font-semibold">${ utils.FormatDecimalWithThousands(row.Summary.TotalCostBasis.StringFixedBank(2)) }</div>
</div>
}
}
}
</div>
}
</div>
}
}
func upperString(s string) string {
out := make([]byte, len(s))
for i := 0; i < len(s); i++ {
c := s[i]
if c >= 'a' && c <= 'z' {
c -= 32
}
out[i] = c
}
return string(out)
}

View file

@ -22,6 +22,8 @@ type SpaceAccountPageProps struct {
RecentTransactions []*model.Transaction
NonEditableTransactionIDs map[string]bool
AllocationSummary *service.AllocationSummary
InvestmentSummary *model.InvestmentAccountSummary
InvestmentPositions []model.HoldingPosition
}
templ SpaceAccountPage(props SpaceAccountPageProps) {
@ -91,11 +93,21 @@ templ SpaceAccountPage(props SpaceAccountPageProps) {
}
}
</div>
@blocks.AllocationsSection(blocks.AllocationsSectionProps{
SpaceID: props.SpaceID,
AccountID: props.AccountID,
Summary: props.AllocationSummary,
})
if props.InvestmentSummary != nil {
@blocks.InvestmentSection(blocks.InvestmentSectionProps{
SpaceID: props.SpaceID,
AccountID: props.AccountID,
Currency: props.AccountCurrency,
Summary: props.InvestmentSummary,
Positions: props.InvestmentPositions,
})
} else {
@blocks.AllocationsSection(blocks.AllocationsSectionProps{
SpaceID: props.SpaceID,
AccountID: props.AccountID,
Summary: props.AllocationSummary,
})
}
<div>
@card.Card() {
@card.Header() {

View file

@ -6,16 +6,19 @@ import "git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
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/dialog"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/form"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
type SpaceAccountSettingsPageProps struct {
SpaceID string
SpaceName string
AccountID string
AccountName string
AccountCurrency string
UpdateForm forms.UpdateAccountProps
CurrencyForm forms.ChangeAccountCurrencyProps
SpaceID string
SpaceName string
AccountID string
AccountName string
AccountCurrency string
IsInvestment bool
InvestmentSubtype string
UpdateForm forms.UpdateAccountProps
CurrencyForm forms.ChangeAccountCurrencyProps
}
templ SpaceAccountSettingsPage(props SpaceAccountSettingsPageProps) {
@ -35,6 +38,68 @@ templ SpaceAccountSettingsPage(props SpaceAccountSettingsPageProps) {
</div>
@forms.UpdateAccount(props.UpdateForm)
@forms.ChangeAccountCurrency(props.CurrencyForm)
@card.Card(card.Props{Class: "rounded-sm"}) {
@card.Header() {
@card.Title() {
Investment account
}
@card.Description() {
Flag this account to track contribution room, holdings, and trades.
}
}
@card.Content() {
{{
selectedSubtype := props.InvestmentSubtype
if selectedSubtype == "" {
selectedSubtype = "tfsa"
}
}}
<form
hx-post={ routeurl.URL("action.app.spaces.space.accounts.account.settings.investment", "spaceID", props.SpaceID, "accountID", props.AccountID) }
class="space-y-4"
>
@form.Item() {
<label class="flex items-center gap-2 text-sm font-medium">
<input
type="checkbox"
name="is_investment"
value="1"
class="h-4 w-4 rounded border-input"
checked?={ props.IsInvestment }
_="on change toggle .hidden on #settings-subtype-wrapper"
/>
Track as an investment account
</label>
}
<div
id="settings-subtype-wrapper"
class={ templ.KV("hidden", !props.IsInvestment) }
>
@form.Item() {
@form.Label(form.LabelProps{For: "settings-subtype"}) {
Account type
}
<select
id="settings-subtype"
name="investment_subtype"
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 border-input"
>
<option value="tfsa" selected?={ selectedSubtype == "tfsa" }>TFSA</option>
<option value="rrsp" selected?={ selectedSubtype == "rrsp" }>RRSP</option>
<option value="fhsa" selected?={ selectedSubtype == "fhsa" }>FHSA</option>
<option value="personal" selected?={ selectedSubtype == "personal" }>Personal / Non-registered</option>
<option value="other" selected?={ selectedSubtype == "other" }>Other</option>
</select>
}
</div>
<div class="flex justify-end">
@button.Button(button.Props{Type: button.TypeSubmit}) {
Save investment settings
}
</div>
</form>
}
}
@card.Card(card.Props{Class: "rounded-sm border-destructive"}) {
@card.Header() {
@card.Title(card.TitleProps{Class: "text-destructive"}) {

View file

@ -99,6 +99,16 @@ templ spaceOverviewSidebarContent() {
<span>Shared with me</span>
}
}
@sidebar.MenuItem() {
@sidebar.MenuButton(sidebar.MenuButtonProps{
Href: routeurl.URL("page.app.investments"),
IsActive: ctxkeys.URLPath(ctx) == routeurl.URL("page.app.investments"),
Tooltip: "Investments",
}) {
@icon.TrendingUp()
<span>Investments</span>
}
}
}
}
}