budgit/internal/ui/blocks/investment_section.templ
juancwu 7c24a8302d
All checks were successful
Deploy / build-and-deploy (push) Successful in 1m50s
feat: investment accounts
2026-05-22 14:49:57 +00:00

220 lines
7 KiB
Text

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
}