feat: loans

This commit is contained in:
juancwu 2026-03-14 11:34:21 -04:00
commit ac7296b06e
No known key found for this signature in database
20 changed files with 3191 additions and 4 deletions

View file

@ -0,0 +1,607 @@
package pages
import (
"fmt"
"strconv"
"git.juancwu.dev/juancwu/budgit/internal/model"
"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/csrf"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
"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/components/pagination"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/progress"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
templ SpaceLoanDetailPage(space *model.Space, loan *model.LoanWithPaymentSummary, receipts []*model.ReceiptWithSourcesAndAccounts, currentPage, totalPages int, recurringReceipts []*model.RecurringReceiptWithSources, accounts []model.MoneyAccountWithBalance, availableBalance int) {
@layouts.Space(loan.Name, space) {
<div class="space-y-6">
// Loan Summary Card
@LoanSummaryCard(space.ID, loan)
// Actions
if !loan.IsPaidOff {
<div class="flex gap-2">
@dialog.Dialog(dialog.Props{}) {
@dialog.Trigger(dialog.TriggerProps{}) {
@button.Button(button.Props{Size: button.SizeSm}) {
@icon.Plus(icon.Props{Class: "size-4 mr-1"})
Make Payment
}
}
@dialog.Content(dialog.ContentProps{Class: "max-w-lg"}) {
@dialog.Header() {
@dialog.Title() {
Make Payment
}
@dialog.Description() {
Record a payment toward { loan.Name }
}
}
@CreateReceiptForm(space.ID, loan.ID, accounts, availableBalance)
}
}
@dialog.Dialog(dialog.Props{}) {
@dialog.Trigger(dialog.TriggerProps{}) {
@button.Button(button.Props{Size: button.SizeSm, Variant: button.VariantOutline}) {
@icon.Repeat(icon.Props{Class: "size-4 mr-1"})
Set Up Recurring
}
}
@dialog.Content(dialog.ContentProps{Class: "max-w-lg"}) {
@dialog.Header() {
@dialog.Title() {
Recurring Payment
}
@dialog.Description() {
Automatically create payments on a schedule
}
}
@CreateRecurringReceiptForm(space.ID, loan.ID, accounts, availableBalance)
}
}
</div>
}
// Recurring Receipts
if len(recurringReceipts) > 0 {
<div class="space-y-2">
<h2 class="text-lg font-semibold">Recurring Payments</h2>
<div class="border rounded-lg divide-y">
for _, rr := range recurringReceipts {
@RecurringReceiptItem(space.ID, loan.ID, rr)
}
</div>
</div>
}
// Receipt History
<div class="space-y-2">
<h2 class="text-lg font-semibold">Payment History</h2>
<div class="border rounded-lg">
<div id="receipts-list-wrapper">
@ReceiptsListContent(space.ID, loan.ID, receipts, currentPage, totalPages)
</div>
</div>
</div>
</div>
}
}
templ LoanSummaryCard(spaceID string, loan *model.LoanWithPaymentSummary) {
{{ progressPct := 0 }}
if loan.OriginalAmountCents > 0 {
{{ progressPct = (loan.TotalPaidCents * 100) / loan.OriginalAmountCents }}
if progressPct > 100 {
{{ progressPct = 100 }}
}
}
@card.Card(card.Props{}) {
@card.Header() {
<div class="flex justify-between items-start">
<div>
@card.Title() {
{ loan.Name }
}
@card.Description() {
if loan.Description != "" {
{ loan.Description }
}
}
</div>
<div class="flex items-center gap-2">
if loan.IsPaidOff {
@badge.Badge(badge.Props{Variant: badge.VariantDefault}) {
Paid Off
}
}
@dialog.Dialog(dialog.Props{}) {
@dialog.Trigger(dialog.TriggerProps{}) {
@button.Button(button.Props{Size: button.SizeIcon, Variant: button.VariantGhost}) {
@icon.Trash2(icon.Props{Class: "size-4"})
}
}
@dialog.Content(dialog.ContentProps{}) {
@dialog.Header() {
@dialog.Title() {
Delete Loan
}
@dialog.Description() {
Are you sure? This will delete all payment records for this loan. Linked expenses and account transfers will be kept as history.
}
}
@dialog.Footer() {
@button.Button(button.Props{
Variant: button.VariantDestructive,
Attributes: templ.Attributes{
"hx-delete": fmt.Sprintf("/app/spaces/%s/loans/%s", spaceID, loan.ID),
},
}) {
Delete
}
}
}
}
</div>
</div>
}
@card.Content() {
<div class="space-y-4">
<div class="grid grid-cols-3 gap-4 text-center">
<div>
<p class="text-sm text-muted-foreground">Original</p>
<p class="text-lg font-semibold">{ fmt.Sprintf("$%.2f", float64(loan.OriginalAmountCents)/100.0) }</p>
</div>
<div>
<p class="text-sm text-muted-foreground">Paid</p>
<p class="text-lg font-semibold text-green-600">{ fmt.Sprintf("$%.2f", float64(loan.TotalPaidCents)/100.0) }</p>
</div>
<div>
<p class="text-sm text-muted-foreground">Remaining</p>
<p class="text-lg font-semibold">
if loan.RemainingCents > 0 {
{ fmt.Sprintf("$%.2f", float64(loan.RemainingCents)/100.0) }
} else {
$0.00
}
</p>
</div>
</div>
@progress.Progress(progress.Props{
Value: progressPct,
Max: 100,
Class: "h-3",
})
<div class="flex justify-between text-sm text-muted-foreground">
<span>{ strconv.Itoa(progressPct) }% paid</span>
if loan.InterestRateBps > 0 {
<span>{ fmt.Sprintf("%.2f%% interest", float64(loan.InterestRateBps)/100.0) }</span>
}
</div>
</div>
}
}
}
templ ReceiptsListContent(spaceID, loanID string, receipts []*model.ReceiptWithSourcesAndAccounts, currentPage, totalPages int) {
<div id="receipts-list" class="divide-y">
if len(receipts) == 0 {
<p class="p-4 text-sm text-muted-foreground">No payments recorded yet.</p>
}
for _, receipt := range receipts {
@ReceiptListItem(spaceID, loanID, receipt)
}
</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/loans/%s/components/receipts?page=%d", spaceID, loanID, currentPage-1),
"hx-target": "#receipts-list-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/loans/%s/components/receipts?page=%d", spaceID, loanID, pg),
"hx-target": "#receipts-list-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/loans/%s/components/receipts?page=%d", spaceID, loanID, currentPage+1),
"hx-target": "#receipts-list-wrapper",
"hx-swap": "innerHTML",
},
})
}
}
}
</div>
}
}
templ ReceiptListItem(spaceID, loanID string, receipt *model.ReceiptWithSourcesAndAccounts) {
<div id={ "receipt-" + receipt.ID } class="p-4 flex justify-between items-start">
<div class="space-y-1">
<div class="flex items-center gap-2">
<span class="font-medium">{ fmt.Sprintf("$%.2f", float64(receipt.TotalAmountCents)/100.0) }</span>
<span class="text-sm text-muted-foreground">{ receipt.Date.Format("Jan 2, 2006") }</span>
if receipt.RecurringReceiptID != nil {
@icon.Repeat(icon.Props{Class: "size-3 text-muted-foreground"})
}
</div>
if receipt.Description != "" {
<p class="text-sm text-muted-foreground">{ receipt.Description }</p>
}
<div class="flex flex-wrap gap-1">
for _, src := range receipt.Sources {
if src.SourceType == "balance" {
@badge.Badge(badge.Props{Variant: badge.VariantSecondary, Class: "text-xs"}) {
{ fmt.Sprintf("Balance $%.2f", float64(src.AmountCents)/100.0) }
}
} else {
@badge.Badge(badge.Props{Variant: badge.VariantOutline, Class: "text-xs"}) {
{ fmt.Sprintf("%s $%.2f", src.AccountName, float64(src.AmountCents)/100.0) }
}
}
}
</div>
</div>
<div class="flex items-center gap-1">
@dialog.Dialog(dialog.Props{}) {
@dialog.Trigger(dialog.TriggerProps{}) {
@button.Button(button.Props{Size: button.SizeIcon, Variant: button.VariantGhost}) {
@icon.Trash2(icon.Props{Class: "size-4"})
}
}
@dialog.Content(dialog.ContentProps{}) {
@dialog.Header() {
@dialog.Title() {
Delete Payment
}
@dialog.Description() {
This will also reverse the linked expense and account transfers.
}
}
@dialog.Footer() {
@button.Button(button.Props{
Variant: button.VariantDestructive,
Attributes: templ.Attributes{
"hx-delete": fmt.Sprintf("/app/spaces/%s/loans/%s/receipts/%s", spaceID, loanID, receipt.ID),
},
}) {
Delete
}
}
}
}
</div>
</div>
}
templ RecurringReceiptItem(spaceID, loanID string, rr *model.RecurringReceiptWithSources) {
<div class="p-4 flex justify-between items-start">
<div class="space-y-1">
<div class="flex items-center gap-2">
@icon.Repeat(icon.Props{Class: "size-4"})
<span class="font-medium">{ fmt.Sprintf("$%.2f", float64(rr.TotalAmountCents)/100.0) }</span>
<span class="text-sm text-muted-foreground">{ string(rr.Frequency) }</span>
if !rr.IsActive {
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
Paused
}
}
</div>
if rr.Description != "" {
<p class="text-sm text-muted-foreground">{ rr.Description }</p>
}
<p class="text-xs text-muted-foreground">
Next: { rr.NextOccurrence.Format("Jan 2, 2006") }
</p>
<div class="flex flex-wrap gap-1">
for _, src := range rr.Sources {
if src.SourceType == "balance" {
@badge.Badge(badge.Props{Variant: badge.VariantSecondary, Class: "text-xs"}) {
{ fmt.Sprintf("Balance $%.2f", float64(src.AmountCents)/100.0) }
}
} else {
@badge.Badge(badge.Props{Variant: badge.VariantOutline, Class: "text-xs"}) {
if src.AccountID != nil {
{ fmt.Sprintf("Account $%.2f", float64(src.AmountCents)/100.0) }
}
}
}
}
</div>
</div>
<div class="flex items-center gap-1">
@button.Button(button.Props{
Size: button.SizeIcon,
Variant: button.VariantGhost,
Attributes: templ.Attributes{
"hx-post": fmt.Sprintf("/app/spaces/%s/loans/%s/recurring/%s/toggle", spaceID, loanID, rr.ID),
},
}) {
if rr.IsActive {
@icon.Pause(icon.Props{Class: "size-4"})
} else {
@icon.Play(icon.Props{Class: "size-4"})
}
}
@dialog.Dialog(dialog.Props{}) {
@dialog.Trigger(dialog.TriggerProps{}) {
@button.Button(button.Props{Size: button.SizeIcon, Variant: button.VariantGhost}) {
@icon.Trash2(icon.Props{Class: "size-4"})
}
}
@dialog.Content(dialog.ContentProps{}) {
@dialog.Header() {
@dialog.Title() {
Delete Recurring Payment
}
@dialog.Description() {
This will stop future automatic payments. Past payments are not affected.
}
}
@dialog.Footer() {
@button.Button(button.Props{
Variant: button.VariantDestructive,
Attributes: templ.Attributes{
"hx-delete": fmt.Sprintf("/app/spaces/%s/loans/%s/recurring/%s", spaceID, loanID, rr.ID),
},
}) {
Delete
}
}
}
}
</div>
</div>
}
templ CreateReceiptForm(spaceID, loanID string, accounts []model.MoneyAccountWithBalance, availableBalance int) {
<form
hx-post={ fmt.Sprintf("/app/spaces/%s/loans/%s/receipts", spaceID, loanID) }
hx-swap="none"
>
@csrf.Token()
@form.Item() {
@form.Label() {
Amount
}
@input.Input(input.Props{
Type: input.TypeNumber,
Name: "amount",
Placeholder: "0.00",
Attributes: templ.Attributes{
"step": "0.01",
"min": "0.01", "required": "true",
},
})
}
@form.Item() {
@form.Label() {
Date
}
@input.Input(input.Props{
Type: input.TypeDate,
Name: "date",
Attributes: templ.Attributes{"required": "true"},
})
}
@form.Item() {
@form.Label() {
Description (optional)
}
@input.Input(input.Props{
Type: input.TypeText,
Name: "description",
Placeholder: "Payment note",
})
}
// Funding Sources
<div class="space-y-2">
<label class="text-sm font-medium">Funding Sources</label>
<p class="text-xs text-muted-foreground">
Available balance: { fmt.Sprintf("$%.2f", float64(availableBalance)/100.0) }
</p>
<div id="funding-sources" class="space-y-2">
<div class="flex gap-2 items-center source-row">
<select name="source_type" class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm">
<option value="balance">General Balance</option>
for _, acct := range accounts {
<option value="account" data-account-id={ acct.ID }>
{ acct.Name } ({ fmt.Sprintf("$%.2f", float64(acct.BalanceCents)/100.0) })
</option>
}
</select>
<input type="hidden" name="source_account_id" value=""/>
@input.Input(input.Props{
Type: input.TypeNumber,
Name: "source_amount",
Placeholder: "0.00",
Attributes: templ.Attributes{
"step": "0.01",
"min": "0.01", "required": "true",
},
})
</div>
</div>
<button
type="button"
class="text-sm text-primary hover:underline"
_="on click
set row to the first .source-row
set clone to row.cloneNode(true)
put '' into the value of the first <select/> in clone
put '' into the value of the first <input[type='hidden']/> in clone
put '' into the value of the first <input[type='number']/> in clone
append clone to #funding-sources"
>
+ Add Source
</button>
</div>
@dialog.Footer() {
@button.Button(button.Props{Type: "submit"}) {
Record Payment
}
}
</form>
<script>
// Update hidden account_id when select changes
document.getElementById('funding-sources').addEventListener('change', function(e) {
if (e.target.tagName === 'SELECT') {
const selected = e.target.options[e.target.selectedIndex];
const hiddenInput = e.target.parentElement.querySelector('input[type="hidden"]');
if (selected.value === 'account') {
hiddenInput.value = selected.dataset.accountId || '';
} else {
hiddenInput.value = '';
}
}
});
</script>
}
templ CreateRecurringReceiptForm(spaceID, loanID string, accounts []model.MoneyAccountWithBalance, availableBalance int) {
<form
hx-post={ fmt.Sprintf("/app/spaces/%s/loans/%s/recurring", spaceID, loanID) }
hx-swap="none"
>
@csrf.Token()
@form.Item() {
@form.Label() {
Amount
}
@input.Input(input.Props{
Type: input.TypeNumber,
Name: "amount",
Placeholder: "0.00",
Attributes: templ.Attributes{
"step": "0.01",
"min": "0.01", "required": "true",
},
})
}
@form.Item() {
@form.Label() {
Frequency
}
<select name="frequency" class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm" required>
<option value="monthly">Monthly</option>
<option value="weekly">Weekly</option>
<option value="biweekly">Biweekly</option>
<option value="yearly">Yearly</option>
</select>
}
@form.Item() {
@form.Label() {
Start Date
}
@input.Input(input.Props{
Type: input.TypeDate,
Name: "start_date",
Attributes: templ.Attributes{"required": "true"},
})
}
@form.Item() {
@form.Label() {
End Date (optional)
}
@input.Input(input.Props{
Type: input.TypeDate,
Name: "end_date",
})
}
@form.Item() {
@form.Label() {
Description (optional)
}
@input.Input(input.Props{
Type: input.TypeText,
Name: "description",
Placeholder: "Payment note",
})
}
// Funding Sources
<div class="space-y-2">
<label class="text-sm font-medium">Funding Sources</label>
<p class="text-xs text-muted-foreground">
Current balance: { fmt.Sprintf("$%.2f", float64(availableBalance)/100.0) }
</p>
<div id="recurring-funding-sources" class="space-y-2">
<div class="flex gap-2 items-center recurring-source-row">
<select name="source_type" class="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm">
<option value="balance">General Balance</option>
for _, acct := range accounts {
<option value="account" data-account-id={ acct.ID }>
{ acct.Name } ({ fmt.Sprintf("$%.2f", float64(acct.BalanceCents)/100.0) })
</option>
}
</select>
<input type="hidden" name="source_account_id" value=""/>
@input.Input(input.Props{
Type: input.TypeNumber,
Name: "source_amount",
Placeholder: "0.00",
Attributes: templ.Attributes{
"step": "0.01",
"min": "0.01", "required": "true",
},
})
</div>
</div>
<button
type="button"
class="text-sm text-primary hover:underline"
_="on click
set row to the first .recurring-source-row
set clone to row.cloneNode(true)
put '' into the value of the first <select/> in clone
put '' into the value of the first <input[type='hidden']/> in clone
put '' into the value of the first <input[type='number']/> in clone
append clone to #recurring-funding-sources"
>
+ Add Source
</button>
</div>
@dialog.Footer() {
@button.Button(button.Props{Type: "submit"}) {
Create Recurring Payment
}
}
</form>
<script>
document.getElementById('recurring-funding-sources').addEventListener('change', function(e) {
if (e.target.tagName === 'SELECT') {
const selected = e.target.options[e.target.selectedIndex];
const hiddenInput = e.target.parentElement.querySelector('input[type="hidden"]');
if (selected.value === 'account') {
hiddenInput.value = selected.dataset.accountId || '';
} else {
hiddenInput.value = '';
}
}
});
</script>
}

View file

@ -0,0 +1,234 @@
package pages
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/card"
"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/badge"
"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/components/pagination"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/progress"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
templ SpaceLoansPage(space *model.Space, loans []*model.LoanWithPaymentSummary, currentPage, totalPages int) {
@layouts.Space("Loans", space) {
<div class="space-y-4">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold">Loans</h1>
@dialog.Dialog(dialog.Props{}) {
@dialog.Trigger(dialog.TriggerProps{}) {
@button.Button(button.Props{Size: button.SizeSm}) {
@icon.Plus(icon.Props{Class: "size-4 mr-1"})
New Loan
}
}
@dialog.Content(dialog.ContentProps{}) {
@dialog.Header() {
@dialog.Title() {
New Loan
}
@dialog.Description() {
Track a new loan or financing
}
}
<form
hx-post={ fmt.Sprintf("/app/spaces/%s/loans", space.ID) }
hx-target="#loans-list-wrapper"
hx-swap="innerHTML"
_="on htmx:afterRequest if event.detail.successful call window.tui.dialog.close() then reset() me"
>
@csrf.Token()
@form.Item() {
@form.Label() {
Name
}
@input.Input(input.Props{
Type: input.TypeText,
Name: "name",
Placeholder: "e.g., Car Loan",
Attributes: templ.Attributes{"required": "true"},
})
}
@form.Item() {
@form.Label() {
Total Amount
}
@input.Input(input.Props{
Type: input.TypeNumber,
Name: "amount",
Placeholder: "0.00",
Attributes: templ.Attributes{
"step": "0.01",
"min": "0.01",
"required": "true",
},
})
}
@form.Item() {
@form.Label() {
Interest Rate (%)
}
@input.Input(input.Props{
Type: input.TypeNumber,
Name: "interest_rate",
Placeholder: "0.00",
Attributes: templ.Attributes{
"step": "0.01",
"min": "0",
},
})
}
@form.Item() {
@form.Label() {
Start Date
}
@input.Input(input.Props{
Type: input.TypeDate,
Name: "start_date",
Attributes: templ.Attributes{"required": "true"},
})
}
@form.Item() {
@form.Label() {
End Date (optional)
}
@input.Input(input.Props{
Type: input.TypeDate,
Name: "end_date",
})
}
@form.Item() {
@form.Label() {
Description (optional)
}
@input.Input(input.Props{
Type: input.TypeText,
Name: "description",
Placeholder: "Additional notes about this loan",
})
}
@dialog.Footer() {
@button.Button(button.Props{Type: "submit"}) {
Create Loan
}
}
</form>
}
}
</div>
<div id="loans-list-wrapper">
@LoansListContent(space.ID, loans, currentPage, totalPages)
</div>
</div>
}
}
templ LoansListContent(spaceID string, loans []*model.LoanWithPaymentSummary, currentPage, totalPages int) {
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
if len(loans) == 0 {
<p class="text-sm text-muted-foreground col-span-full">No loans yet. Create one to start tracking payments.</p>
}
for _, loan := range loans {
@LoanCard(spaceID, loan)
}
</div>
if totalPages > 1 {
<div class="mt-4">
@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/loans?page=%d", spaceID, currentPage-1),
"hx-target": "#loans-list-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/loans?page=%d", spaceID, pg),
"hx-target": "#loans-list-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/loans?page=%d", spaceID, currentPage+1),
"hx-target": "#loans-list-wrapper",
"hx-swap": "innerHTML",
},
})
}
}
}
</div>
}
}
templ LoanCard(spaceID string, loan *model.LoanWithPaymentSummary) {
{{ progressPct := 0 }}
if loan.OriginalAmountCents > 0 {
{{ progressPct = (loan.TotalPaidCents * 100) / loan.OriginalAmountCents }}
if progressPct > 100 {
{{ progressPct = 100 }}
}
}
<a href={ templ.SafeURL(fmt.Sprintf("/app/spaces/%s/loans/%s", spaceID, loan.ID)) } class="block">
@card.Card(card.Props{Class: "hover:border-primary/50 transition-colors cursor-pointer"}) {
@card.Header() {
<div class="flex justify-between items-start">
@card.Title() {
{ loan.Name }
}
if loan.IsPaidOff {
@badge.Badge(badge.Props{Variant: badge.VariantDefault}) {
Paid Off
}
}
</div>
@card.Description() {
{ fmt.Sprintf("$%.2f", float64(loan.OriginalAmountCents)/100.0) }
if loan.InterestRateBps > 0 {
{ fmt.Sprintf(" @ %.2f%%", float64(loan.InterestRateBps)/100.0) }
}
}
}
@card.Content() {
@progress.Progress(progress.Props{
Value: progressPct,
Max: 100,
Class: "h-2",
})
<div class="flex justify-between text-sm text-muted-foreground mt-2">
<span>Paid: { fmt.Sprintf("$%.2f", float64(loan.TotalPaidCents)/100.0) }</span>
if loan.RemainingCents > 0 {
<span>Left: { fmt.Sprintf("$%.2f", float64(loan.RemainingCents)/100.0) }</span>
} else {
<span class="text-green-600">Fully paid</span>
}
</div>
<p class="text-xs text-muted-foreground mt-1">
{ strconv.Itoa(loan.ReceiptCount) } payment(s)
</p>
}
}
</a>
}