chore: massive reset

This commit is contained in:
juancwu 2026-04-06 17:51:59 +00:00
commit df164ab0f4
96 changed files with 198 additions and 15405 deletions

View file

@ -1,94 +0,0 @@
package pages
import (
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
"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/icon"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/label"
)
templ Dashboard(spaces []*model.Space) {
@layouts.App("Spaces") {
<div class="container max-w-7xl px-6 py-8">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-8 gap-4">
<div>
<h1 class="text-3xl font-bold">Spaces</h1>
<p class="text-muted-foreground mt-2">
Welcome back!
</p>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
for _, space := range spaces {
<a href={ templ.SafeURL("/app/spaces/" + space.ID) } class="block hover:no-underline group">
@card.Card(card.Props{Class: "h-full transition-colors group-hover:border-primary"}) {
@card.Header() {
@card.Title() {
{ space.Name }
}
@card.Description() {
Manage expenses in this space.
}
}
@card.Content()
}
</a>
}
// Option to create a new space
@dialog.Dialog(dialog.Props{ID: "create-space-dialog"}) {
@dialog.Trigger() {
@card.Card(card.Props{Class: "h-full border-dashed cursor-pointer transition-colors hover:border-primary"}) {
@card.Content(card.ContentProps{Class: "h-full flex flex-col items-center justify-center py-12"}) {
@icon.Plus(icon.Props{Class: "h-8 w-8 text-muted-foreground mb-2"})
<p class="text-muted-foreground">Create a new space</p>
}
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Create Space
}
@dialog.Description() {
Create a new space to organize expenses and more.
}
}
<form hx-post="/app/spaces" hx-swap="none" class="space-y-4">
@csrf.Token()
<div class="space-y-2">
@label.Label(label.Props{For: "space-name"}) {
Name
}
@input.Input(input.Props{
ID: "space-name",
Name: "name",
Type: input.TypeText,
Placeholder: "e.g. Household, Trip, Roommates",
Attributes: templ.Attributes{
"describedby": "create-space-error",
},
})
<p id="create-space-error" class="text-sm text-destructive"></p>
</div>
@dialog.Footer() {
@dialog.Close(dialog.CloseProps{For: "create-space-dialog"}) {
@button.Button(button.Props{Variant: button.VariantOutline, Type: button.TypeButton}) {
Cancel
}
}
@button.Submit() {
Create
}
}
</form>
}
}
</div>
</div>
}
}

View file

@ -1,7 +1,6 @@
package pages
import (
"git.juancwu.dev/juancwu/budgit/internal/timezone"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/csrf"
@ -9,55 +8,15 @@ import (
"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/card"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/selectbox"
)
templ AppSettings(hasPassword bool, errorMsg string, currentTimezone string) {
templ AppSettings(hasPassword bool, errorMsg string) {
@layouts.App("Settings") {
<div class="container max-w-2xl px-6 py-8">
<div class="mb-8">
<h1 class="text-3xl font-bold">Settings</h1>
<p class="text-muted-foreground mt-2">Manage your account settings</p>
</div>
@card.Card() {
@card.Header() {
@card.Title() {
Timezone
}
@card.Description() {
Set your timezone for recurring expenses and reports
}
}
@card.Content() {
<form action="/app/settings/timezone" method="POST" class="space-y-4">
@csrf.Token()
@form.Item() {
@label.Label(label.Props{
For: "timezone",
Class: "block mb-2",
}) {
Timezone
}
@selectbox.SelectBox(selectbox.Props{ID: "timezone-select"}) {
@selectbox.Trigger(selectbox.TriggerProps{Name: "timezone"}) {
@selectbox.Value(selectbox.ValueProps{Placeholder: "Select timezone"})
}
@selectbox.Content(selectbox.ContentProps{SearchPlaceholder: "Search timezones..."}) {
for _, tz := range timezone.CommonTimezones() {
@selectbox.Item(selectbox.ItemProps{Value: tz.Value, Selected: tz.Value == currentTimezone}) {
{ tz.Label }
}
}
}
}
}
@button.Submit() {
Update Timezone
}
</form>
}
}
<div class="mt-6"></div>
@card.Card() {
@card.Header() {
@card.Title() {

View file

@ -1,48 +0,0 @@
package pages
import (
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/moneyaccount"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
"github.com/shopspring/decimal"
)
templ SpaceAccountsPage(space *model.Space, accounts []model.MoneyAccountWithBalance, totalBalance decimal.Decimal, availableBalance decimal.Decimal, transfers []*model.AccountTransferWithAccount, currentPage, totalPages int) {
@layouts.Space("Accounts", space) {
<div class="space-y-4">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold">Money Accounts</h1>
@dialog.Dialog(dialog.Props{ID: "add-account-dialog"}) {
@dialog.Trigger() {
@button.Button() {
New Account
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Create Account
}
@dialog.Description() {
Create a new money account to set aside funds.
}
}
@moneyaccount.CreateAccountForm(space.ID, "add-account-dialog")
}
}
</div>
@moneyaccount.BalanceSummaryCard(space.ID, totalBalance, availableBalance, false)
<div id="accounts-list" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
if len(accounts) == 0 {
<p class="text-sm text-muted-foreground col-span-full">No money accounts yet. Create one to start allocating funds.</p>
}
for _, acct := range accounts {
@moneyaccount.AccountCard(space.ID, &acct)
}
</div>
@moneyaccount.TransferHistorySection(space.ID, transfers, currentPage, totalPages)
</div>
}
}

View file

@ -1,398 +0,0 @@
package pages
import (
"fmt"
"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/datepicker"
"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/radio"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/tagcombobox"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
func periodLabel(p model.BudgetPeriod) string {
switch p {
case model.BudgetPeriodWeekly:
return "Weekly"
case model.BudgetPeriodYearly:
return "Yearly"
default:
return "Monthly"
}
}
func progressBarColor(status model.BudgetStatus) string {
switch status {
case model.BudgetStatusOver:
return "bg-destructive"
case model.BudgetStatusWarning:
return "bg-yellow-500"
default:
return "bg-green-500"
}
}
templ SpaceBudgetsPage(space *model.Space, budgets []*model.BudgetWithSpent, tags []*model.Tag) {
@layouts.Space("Budgets", space) {
<div class="space-y-4">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold">Budgets</h1>
@dialog.Dialog(dialog.Props{ID: "add-budget-dialog"}) {
@dialog.Trigger() {
@button.Button() {
Add Budget
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Add Budget
}
@dialog.Description() {
Set a spending limit for one or more tag categories.
}
}
@AddBudgetForm(space.ID, tags)
}
}
</div>
<div id="budgets-list-wrapper">
@BudgetsList(space.ID, budgets, tags)
</div>
</div>
}
}
templ BudgetsList(spaceID string, budgets []*model.BudgetWithSpent, tags []*model.Tag) {
<div id="budgets-list" class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
if len(budgets) == 0 {
<p class="text-sm text-muted-foreground col-span-full">No budgets set up yet.</p>
}
for _, b := range budgets {
@BudgetCard(spaceID, b, tags)
}
</div>
}
templ BudgetCard(spaceID string, b *model.BudgetWithSpent, tags []*model.Tag) {
{{ editDialogID := "edit-budget-" + b.ID }}
{{ delDialogID := "del-budget-" + b.ID }}
{{ pct := b.Percentage }}
if pct > 100 {
{{ pct = 100 }}
}
<div id={ "budget-" + b.ID } class="border rounded-lg p-4 bg-card text-card-foreground space-y-3">
<div class="flex justify-between items-start">
<div>
<div class="flex items-center gap-2 flex-wrap">
for _, t := range b.Tags {
<span class="inline-flex items-center gap-1">
if t.Color != nil {
<span class="inline-block w-3 h-3 rounded-full" style={ "background-color: " + *t.Color }></span>
}
<span class="text-sm font-semibold">{ t.Name }</span>
</span>
}
</div>
<p class="text-xs text-muted-foreground">{ periodLabel(b.Period) } budget</p>
</div>
<div class="flex gap-1">
@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 Budget
}
@dialog.Description() {
Update this budget's settings.
}
}
@EditBudgetForm(spaceID, b, tags)
}
}
@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 Budget
}
@dialog.Description() {
Are you sure you want to delete this budget?
}
}
@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/budgets/%s", spaceID, b.ID),
"hx-target": "#budget-" + b.ID,
"hx-swap": "outerHTML",
},
}) {
Delete
}
}
}
}
</div>
</div>
// Progress bar
<div class="space-y-1">
<div class="flex justify-between text-sm">
<span>{ model.FormatMoney(b.Spent) } spent</span>
<span>of { model.FormatMoney(b.Amount) }</span>
</div>
<div class="w-full bg-muted rounded-full h-2.5">
<div class={ "h-2.5 rounded-full transition-all", progressBarColor(b.Status) } style={ fmt.Sprintf("width: %.1f%%", pct) }></div>
</div>
if b.Status == model.BudgetStatusOver {
<p class="text-xs text-destructive font-medium">Over budget by { model.FormatMoney(b.Spent.Sub(b.Amount)) }</p>
}
</div>
</div>
}
templ AddBudgetForm(spaceID string, tags []*model.Tag) {
<form
hx-post={ "/app/spaces/" + spaceID + "/budgets" }
hx-target="#budgets-list-wrapper"
hx-swap="innerHTML"
_="on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('add-budget-dialog') then reset() me end"
class="space-y-4"
>
@csrf.Token()
// Tag selector
<div>
@label.Label(label.Props{For: "budget-tags"}) {
Tags
}
@tagcombobox.TagCombobox(tagcombobox.Props{
ID: "budget-tags",
Name: "tags",
Tags: tags,
Placeholder: "Search or create tags...",
})
</div>
// Amount
<div>
@label.Label(label.Props{For: "budget-amount"}) {
Budget Amount
}
@input.Input(input.Props{
Name: "amount",
ID: "budget-amount",
Type: "number",
Attributes: templ.Attributes{"step": "0.01", "required": "true"},
})
</div>
// Period
<div>
@label.Label(label.Props{}) {
Period
}
<div class="flex gap-4">
<div class="flex items-center gap-2">
@radio.Radio(radio.Props{
ID: "budget-period-monthly",
Name: "period",
Value: "monthly",
Checked: true,
})
@label.Label(label.Props{For: "budget-period-monthly"}) {
Monthly
}
</div>
<div class="flex items-center gap-2">
@radio.Radio(radio.Props{
ID: "budget-period-weekly",
Name: "period",
Value: "weekly",
})
@label.Label(label.Props{For: "budget-period-weekly"}) {
Weekly
}
</div>
<div class="flex items-center gap-2">
@radio.Radio(radio.Props{
ID: "budget-period-yearly",
Name: "period",
Value: "yearly",
})
@label.Label(label.Props{For: "budget-period-yearly"}) {
Yearly
}
</div>
</div>
</div>
// Start Date
<div>
@label.Label(label.Props{For: "budget-start-date"}) {
Start Date
}
@datepicker.DatePicker(datepicker.Props{
ID: "budget-start-date",
Name: "start_date",
Required: true,
Clearable: true,
})
</div>
// End Date (optional)
<div>
@label.Label(label.Props{For: "budget-end-date"}) {
End Date (optional)
}
@datepicker.DatePicker(datepicker.Props{
ID: "budget-end-date",
Name: "end_date",
Clearable: true,
})
</div>
<div class="flex justify-end">
@button.Submit() {
Save
}
</div>
</form>
}
templ EditBudgetForm(spaceID string, b *model.BudgetWithSpent, tags []*model.Tag) {
{{ editDialogID := "edit-budget-" + b.ID }}
{{ budgetTagNames := make([]string, len(b.Tags)) }}
for i, t := range b.Tags {
{{ budgetTagNames[i] = t.Name }}
}
<form
hx-patch={ fmt.Sprintf("/app/spaces/%s/budgets/%s", spaceID, b.ID) }
hx-target="#budgets-list-wrapper"
hx-swap="innerHTML"
_={ "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + editDialogID + "') end" }
class="space-y-4"
>
@csrf.Token()
// Tag selector
<div>
@label.Label(label.Props{For: "edit-budget-tags-" + b.ID}) {
Tags
}
@tagcombobox.TagCombobox(tagcombobox.Props{
ID: "edit-budget-tags-" + b.ID,
Name: "tags",
Value: budgetTagNames,
Tags: tags,
Placeholder: "Search or create tags...",
})
</div>
// Amount
<div>
@label.Label(label.Props{For: "edit-budget-amount-" + b.ID}) {
Budget Amount
}
@input.Input(input.Props{
Name: "amount",
ID: "edit-budget-amount-" + b.ID,
Type: "number",
Value: model.FormatDecimal(b.Amount),
Attributes: templ.Attributes{"step": "0.01", "required": "true"},
})
</div>
// Period
<div>
@label.Label(label.Props{}) {
Period
}
<div class="flex gap-4">
<div class="flex items-center gap-2">
@radio.Radio(radio.Props{
ID: "edit-budget-period-monthly-" + b.ID,
Name: "period",
Value: "monthly",
Checked: b.Period == model.BudgetPeriodMonthly,
})
@label.Label(label.Props{For: "edit-budget-period-monthly-" + b.ID}) {
Monthly
}
</div>
<div class="flex items-center gap-2">
@radio.Radio(radio.Props{
ID: "edit-budget-period-weekly-" + b.ID,
Name: "period",
Value: "weekly",
Checked: b.Period == model.BudgetPeriodWeekly,
})
@label.Label(label.Props{For: "edit-budget-period-weekly-" + b.ID}) {
Weekly
}
</div>
<div class="flex items-center gap-2">
@radio.Radio(radio.Props{
ID: "edit-budget-period-yearly-" + b.ID,
Name: "period",
Value: "yearly",
Checked: b.Period == model.BudgetPeriodYearly,
})
@label.Label(label.Props{For: "edit-budget-period-yearly-" + b.ID}) {
Yearly
}
</div>
</div>
</div>
// Start Date
<div>
@label.Label(label.Props{For: "edit-budget-start-" + b.ID}) {
Start Date
}
@datepicker.DatePicker(datepicker.Props{
ID: "edit-budget-start-" + b.ID,
Name: "start_date",
Value: b.StartDate,
Clearable: true,
Required: true,
})
</div>
// End Date
<div>
@label.Label(label.Props{For: "edit-budget-end-" + b.ID}) {
End Date (optional)
}
if b.EndDate != nil {
@datepicker.DatePicker(datepicker.Props{
ID: "edit-budget-end-" + b.ID,
Name: "end_date",
Value: *b.EndDate,
Clearable: true,
})
} else {
@datepicker.DatePicker(datepicker.Props{
ID: "edit-budget-end-" + b.ID,
Name: "end_date",
Clearable: true,
})
}
</div>
<div class="flex justify-end">
@button.Submit() {
Save
}
</div>
</form>
}

View file

@ -1,198 +0,0 @@
package pages
import (
"fmt"
"strconv"
"github.com/shopspring/decimal"
"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/dialog"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/expense"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/pagination"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
"git.juancwu.dev/juancwu/budgit/internal/ui/blocks/dialogs"
)
templ SpaceExpensesPage(space *model.Space, expenses []*model.ExpenseWithTagsAndMethod, balance decimal.Decimal, allocated decimal.Decimal, tags []*model.Tag, listsWithItems []model.ListWithUncheckedItems, methods []*model.PaymentMethod, currentPage, totalPages int) {
@layouts.Space("Expenses", space) {
<div class="space-y-4">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold">Expenses</h1>
@dialogs.AddTransaction(space, tags, listsWithItems, methods)
</div>
// Balance Card
@expense.BalanceCard(space.ID, balance, allocated, false)
// List of expenses
<div class="border rounded-lg">
<div id="expenses-list-wrapper">
@ExpensesListContent(space.ID, expenses, methods, tags, currentPage, totalPages)
</div>
</div>
</div>
}
}
templ ExpensesListContent(spaceID string, expenses []*model.ExpenseWithTagsAndMethod, methods []*model.PaymentMethod, tags []*model.Tag, currentPage, totalPages int) {
<h2 class="text-lg font-semibold p-4">History</h2>
<div id="expenses-list" class="divide-y">
if len(expenses) == 0 {
<p class="p-4 text-sm text-muted-foreground">No expenses recorded yet.</p>
}
for _, exp := range expenses {
@ExpenseListItem(spaceID, exp, methods, tags)
}
</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/expenses?page=%d", spaceID, currentPage-1),
"hx-target": "#expenses-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/components/expenses?page=%d", spaceID, pg),
"hx-target": "#expenses-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/components/expenses?page=%d", spaceID, currentPage+1),
"hx-target": "#expenses-list-wrapper",
"hx-swap": "innerHTML",
},
})
}
}
}
</div>
}
}
templ ExpenseListItem(spaceID string, exp *model.ExpenseWithTagsAndMethod, methods []*model.PaymentMethod, tags []*model.Tag) {
<div id={ "expense-" + exp.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">{ exp.Description }</p>
if exp.RecurringExpenseID != nil {
@icon.Repeat(icon.Props{Size: 14, Class: "text-muted-foreground shrink-0"})
}
</div>
<p class="text-sm text-muted-foreground">
{ exp.Date.Format("Jan 02, 2006") }
if exp.PaymentMethod != nil {
if exp.PaymentMethod.LastFour != nil {
<span>&middot; { exp.PaymentMethod.Name } (*{ *exp.PaymentMethod.LastFour })</span>
} else {
<span>&middot; { exp.PaymentMethod.Name }</span>
}
} else {
<span>&middot; Cash</span>
}
</p>
if len(exp.Tags) > 0 {
<div class="flex flex-wrap gap-1 mt-1">
for _, t := range exp.Tags {
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
{ t.Name }
}
}
</div>
}
</div>
<div class="flex items-center gap-1 shrink-0">
if exp.Type == model.ExpenseTypeExpense {
<p class="font-bold text-destructive">
- { model.FormatMoney(exp.Amount) }
</p>
} else {
<p class="font-bold text-green-500">
+ { model.FormatMoney(exp.Amount) }
</p>
}
// Edit button
@dialog.Dialog(dialog.Props{ID: "edit-expense-" + exp.ID}) {
@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 Transaction
}
@dialog.Description() {
Update the details of this transaction.
}
}
@expense.EditExpenseForm(spaceID, exp, methods, tags)
}
}
// Delete button
@dialog.Dialog(dialog.Props{ID: "del-expense-" + exp.ID}) {
@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 Transaction
}
@dialog.Description() {
Are you sure you want to delete "{ exp.Description }"? This action cannot be undone.
}
}
@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/expenses/%s", spaceID, exp.ID),
"hx-target": "#expense-" + exp.ID,
"hx-swap": "outerHTML",
},
}) {
Delete
}
}
}
}
</div>
</div>
}
templ ExpenseCreatedResponse(spaceID string, expenses []*model.ExpenseWithTagsAndMethod, balance decimal.Decimal, allocated decimal.Decimal, tags []*model.Tag, currentPage, totalPages int) {
@ExpensesListContent(spaceID, expenses, nil, tags, currentPage, totalPages)
@expense.BalanceCard(spaceID, balance, allocated, true)
}
templ ExpenseUpdatedResponse(spaceID string, exp *model.ExpenseWithTagsAndMethod, balance decimal.Decimal, allocated decimal.Decimal, methods []*model.PaymentMethod, tags []*model.Tag) {
@ExpenseListItem(spaceID, exp, methods, tags)
@expense.BalanceCard(exp.SpaceID, balance, allocated, true)
}

View file

@ -1,88 +0,0 @@
package pages
import (
"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/input"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/shoppinglist"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
templ SpaceListDetailPage(space *model.Space, list *model.ShoppingList, items []*model.ListItem) {
@layouts.Space(list.Name, space) {
<div class="space-y-4">
<div class="flex items-center justify-between">
@shoppinglist.ListNameHeader(space.ID, list)
@dialog.Dialog(dialog.Props{ID: "delete-list-dialog"}) {
@dialog.Trigger() {
@button.Button(button.Props{Variant: button.VariantGhost}) {
Delete List
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Delete Shopping List
}
@dialog.Description() {
Are you sure you want to delete "{ list.Name }"? This will permanently remove the list and all its items. This action cannot be undone.
}
}
@dialog.Footer() {
@dialog.Close() {
@button.Button(button.Props{Variant: button.VariantOutline}) {
Cancel
}
}
@button.Button(button.Props{
Variant: button.VariantDestructive,
Attributes: templ.Attributes{
"hx-delete": "/app/spaces/" + space.ID + "/lists/" + list.ID,
},
}) {
Delete
}
}
}
}
</div>
<form
hx-post={ "/app/spaces/" + space.ID + "/lists/" + list.ID + "/items" }
hx-target="#items-container"
hx-swap="beforeend"
_="on htmx:afterRequest reset() me"
class="flex gap-2 items-start"
>
@csrf.Token()
@input.Input(input.Props{
Name: "name",
Placeholder: "New item...",
Attributes: templ.Attributes{
"autocomplete": "off",
},
})
@button.Submit() {
Add Item
}
</form>
<div
id="items-container"
class="border rounded-lg"
>
@ShoppingListItems(space.ID, items)
</div>
</div>
}
}
templ ShoppingListItems(spaceID string, items []*model.ListItem) {
if len(items) == 0 {
<p class="text-center text-muted-foreground p-8">This list is empty.</p>
} else {
for _, item := range items {
@shoppinglist.ItemDetail(spaceID, item)
}
}
}

View file

@ -1,48 +0,0 @@
package pages
import (
"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/input"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/shoppinglist"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
templ SpaceListsPage(space *model.Space, cards []model.ListCardData) {
@layouts.Space("Shopping Lists", space) {
<div class="space-y-4">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold">Shopping Lists</h1>
</div>
<form
hx-post={ "/app/spaces/" + space.ID + "/lists" }
hx-target="#lists-container"
hx-swap="beforeend"
_="on htmx:afterRequest reset() me"
class="flex gap-2 items-start"
>
@csrf.Token()
@input.Input(input.Props{
Name: "name",
Placeholder: "New list name...",
})
@button.Submit() {
Create
}
</form>
<div
id="lists-container"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"
>
@ListsContainer(space.ID, cards)
</div>
</div>
}
}
templ ListsContainer(spaceID string, cards []model.ListCardData) {
for _, card := range cards {
@shoppinglist.ListCard(spaceID, card.List, card.Items, card.CurrentPage, card.TotalPages)
}
}

View file

@ -1,608 +0,0 @@
package pages
import (
"fmt"
"strconv"
"github.com/shopspring/decimal"
"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 decimal.Decimal) {
@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.OriginalAmount.IsZero() {
{{ progressPct = int(loan.TotalPaid.Div(loan.OriginalAmount).Mul(decimal.NewFromInt(100)).IntPart()) }}
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">{ model.FormatMoney(loan.OriginalAmount) }</p>
</div>
<div>
<p class="text-sm text-muted-foreground">Paid</p>
<p class="text-lg font-semibold text-green-600">{ model.FormatMoney(loan.TotalPaid) }</p>
</div>
<div>
<p class="text-sm text-muted-foreground">Remaining</p>
<p class="text-lg font-semibold">
if loan.Remaining.GreaterThan(decimal.Zero) {
{ model.FormatMoney(loan.Remaining) }
} 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">{ model.FormatMoney(receipt.TotalAmount) }</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 %s", model.FormatMoney(src.Amount)) }
}
} else {
@badge.Badge(badge.Props{Variant: badge.VariantOutline, Class: "text-xs"}) {
{ fmt.Sprintf("%s %s", src.AccountName, model.FormatMoney(src.Amount)) }
}
}
}
</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">{ model.FormatMoney(rr.TotalAmount) }</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 %s", model.FormatMoney(src.Amount)) }
}
} else {
@badge.Badge(badge.Props{Variant: badge.VariantOutline, Class: "text-xs"}) {
if src.AccountID != nil {
{ fmt.Sprintf("Account %s", model.FormatMoney(src.Amount)) }
}
}
}
}
</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 decimal.Decimal) {
<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: { model.FormatMoney(availableBalance) }
</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 } ({ model.FormatMoney(acct.Balance) })
</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 decimal.Decimal) {
<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: { model.FormatMoney(availableBalance) }
</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 } ({ model.FormatMoney(acct.Balance) })
</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

@ -1,235 +0,0 @@
package pages
import (
"fmt"
"strconv"
"github.com/shopspring/decimal"
"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.OriginalAmount.IsZero() {
{{ progressPct = int(loan.TotalPaid.Div(loan.OriginalAmount).Mul(decimal.NewFromInt(100)).IntPart()) }}
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() {
{ model.FormatMoney(loan.OriginalAmount) }
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: { model.FormatMoney(loan.TotalPaid) }</span>
if loan.Remaining.GreaterThan(decimal.Zero) {
<span>Left: { model.FormatMoney(loan.Remaining) }</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>
}

View file

@ -1,385 +0,0 @@
package pages
import (
"fmt"
"sort"
"github.com/shopspring/decimal"
"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/chart"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
"git.juancwu.dev/juancwu/budgit/internal/ui/blocks/dialogs"
)
type OverviewData struct {
Space *model.Space
Balance decimal.Decimal
Allocated decimal.Decimal
Report *model.SpendingReport
Budgets []*model.BudgetWithSpent
UpcomingRecurring []*model.RecurringExpenseWithTagsAndMethod
ShoppingLists []model.ListCardData
Tags []*model.Tag
Methods []*model.PaymentMethod
ListsWithItems []model.ListWithUncheckedItems
}
func overviewProgressBarColor(status model.BudgetStatus) string {
switch status {
case model.BudgetStatusOver:
return "bg-destructive"
case model.BudgetStatusWarning:
return "bg-yellow-500"
default:
return "bg-green-500"
}
}
func overviewPeriodLabel(p model.BudgetPeriod) string {
switch p {
case model.BudgetPeriodWeekly:
return "Weekly"
case model.BudgetPeriodYearly:
return "Yearly"
default:
return "Monthly"
}
}
func overviewFrequencyLabel(f model.Frequency) string {
switch f {
case model.FrequencyDaily:
return "Daily"
case model.FrequencyWeekly:
return "Weekly"
case model.FrequencyBiweekly:
return "Biweekly"
case model.FrequencyMonthly:
return "Monthly"
case model.FrequencyYearly:
return "Yearly"
default:
return string(f)
}
}
func sortedActiveRecurring(recs []*model.RecurringExpenseWithTagsAndMethod) []*model.RecurringExpenseWithTagsAndMethod {
var active []*model.RecurringExpenseWithTagsAndMethod
for _, r := range recs {
if r.IsActive {
active = append(active, r)
}
}
sort.Slice(active, func(i, j int) bool {
return active[i].NextOccurrence.Before(active[j].NextOccurrence)
})
return active
}
func uncheckedCount(items []*model.ListItem) int {
count := 0
for _, item := range items {
if !item.IsChecked {
count++
}
}
return count
}
var overviewChartColors = []string{
"#3b82f6", "#ef4444", "#22c55e", "#f59e0b", "#8b5cf6",
"#ec4899", "#06b6d4", "#f97316", "#14b8a6", "#6366f1",
}
func overviewChartColor(i int, tagColor *string) string {
if tagColor != nil && *tagColor != "" {
return *tagColor
}
return overviewChartColors[i%len(overviewChartColors)]
}
templ SpaceOverviewPage(data OverviewData) {
@layouts.Space("Overview", data.Space) {
@chart.Script()
<div class="space-y-4">
<h1 class="text-2xl font-bold">Overview</h1>
<div class="grid gap-4 md:grid-cols-2">
// Row 1: Balance + This Month summary
@overviewBalanceCard(data)
@overviewSpendingCard(data)
// Row 2: Charts
@overviewSpendingByCategoryChart(data)
@overviewSpendingOverTimeChart(data)
// Row 3+: Detail cards
@overviewBudgetsCard(data)
@overviewRecurringCard(data)
@overviewTopExpensesCard(data)
@overviewShoppingListsCard(data)
</div>
</div>
}
}
templ overviewSectionHeader(title, href string) {
<div class="flex justify-between items-center mb-3">
<h3 class="font-semibold">{ title }</h3>
@button.Button(button.Props{
Variant: button.VariantGhost,
Size: button.SizeSm,
Attributes: templ.Attributes{
"onclick": "window.location.href='" + href + "'",
},
}) {
<span>View all</span>
@icon.ChevronRight(icon.Props{Size: 16})
}
</div>
}
templ overviewBalanceCard(data OverviewData) {
<div class="border rounded-lg p-4 bg-card text-card-foreground">
<div class="flex items-center justify-between">
<h3 class="font-semibold mb-3">Current Balance</h3>
@dialogs.AddTransaction(data.Space, data.Tags, data.ListsWithItems, data.Methods)
</div>
<p class={ "text-3xl font-bold", templ.KV("text-destructive", data.Balance.IsNegative()) }>
{ model.FormatMoney(data.Balance) }
</p>
if data.Allocated.GreaterThan(decimal.Zero) {
<p class="text-sm text-muted-foreground mt-1">
{ model.FormatMoney(data.Allocated) } in accounts
</p>
}
</div>
}
templ overviewSpendingCard(data OverviewData) {
<div class="border rounded-lg p-4 bg-card text-card-foreground">
@overviewSectionHeader("This Month", "/app/spaces/"+data.Space.ID+"/reports")
if data.Report != nil {
<div class="space-y-1">
<div class="flex justify-between">
<span class="text-sm text-green-500 font-medium">Income</span>
<span class="text-sm font-bold text-green-500">{ model.FormatMoney(data.Report.TotalIncome) }</span>
</div>
<div class="flex justify-between">
<span class="text-sm text-destructive font-medium">Expenses</span>
<span class="text-sm font-bold text-destructive">{ model.FormatMoney(data.Report.TotalExpenses) }</span>
</div>
<hr class="border-border"/>
<div class="flex justify-between">
<span class="text-sm font-medium">Net</span>
<span class={ "text-sm font-bold", templ.KV("text-green-500", !data.Report.NetBalance.IsNegative()), templ.KV("text-destructive", data.Report.NetBalance.IsNegative()) }>
{ model.FormatMoney(data.Report.NetBalance) }
</span>
</div>
</div>
} else {
<p class="text-sm text-muted-foreground">No data available.</p>
}
</div>
}
templ overviewSpendingByCategoryChart(data OverviewData) {
<div class="border rounded-lg p-4 bg-card text-card-foreground min-w-0 overflow-hidden">
<h3 class="font-semibold mb-2">Spending by Category</h3>
if data.Report != nil && len(data.Report.ByTag) > 0 {
{{
tagLabels := make([]string, len(data.Report.ByTag))
tagData := make([]float64, len(data.Report.ByTag))
tagColors := make([]string, len(data.Report.ByTag))
for i, t := range data.Report.ByTag {
tagLabels[i] = t.TagName
tagData[i] = t.TotalAmount.InexactFloat64()
tagColors[i] = overviewChartColor(i, t.TagColor)
}
}}
@chart.Chart(chart.Props{
Variant: chart.VariantDoughnut,
ShowLegend: true,
Data: chart.Data{
Labels: tagLabels,
Datasets: []chart.Dataset{
{
Label: "Spending",
Data: tagData,
BackgroundColor: tagColors,
},
},
},
})
} else {
<p class="text-sm text-muted-foreground">No tagged expenses this month.</p>
}
</div>
}
templ overviewSpendingOverTimeChart(data OverviewData) {
<div class="border rounded-lg p-4 bg-card text-card-foreground min-w-0 overflow-hidden">
<h3 class="font-semibold mb-2">Spending Over Time</h3>
if data.Report != nil && len(data.Report.DailySpending) > 0 {
{{
var timeLabels []string
var timeData []float64
for _, d := range data.Report.DailySpending {
timeLabels = append(timeLabels, d.Date.Format("Jan 02"))
timeData = append(timeData, d.Total.InexactFloat64())
}
}}
@chart.Chart(chart.Props{
Variant: chart.VariantBar,
ShowYAxis: true,
ShowXAxis: true,
ShowXLabels: true,
ShowYLabels: true,
Data: chart.Data{
Labels: timeLabels,
Datasets: []chart.Dataset{
{
Label: "Spending",
Data: timeData,
BackgroundColor: "#3b82f6",
},
},
},
})
} else {
<p class="text-sm text-muted-foreground">No expenses this month.</p>
}
</div>
}
templ overviewBudgetsCard(data OverviewData) {
<div class="border rounded-lg p-4 bg-card text-card-foreground">
@overviewSectionHeader("Budget Status", "/app/spaces/"+data.Space.ID+"/budgets")
if len(data.Budgets) == 0 {
<p class="text-sm text-muted-foreground">No budgets set up yet.</p>
} else {
<div class="space-y-3">
for i, b := range data.Budgets {
if i < 3 {
{{ pct := b.Percentage }}
if pct > 100 {
{{ pct = 100 }}
}
<div class="space-y-1">
<div class="flex items-center gap-2 flex-wrap">
for _, t := range b.Tags {
<span class="inline-flex items-center gap-1">
if t.Color != nil {
<span class="inline-block w-2.5 h-2.5 rounded-full" style={ "background-color: " + *t.Color }></span>
}
<span class="text-sm font-medium">{ t.Name }</span>
</span>
}
<span class="text-xs text-muted-foreground ml-auto">{ overviewPeriodLabel(b.Period) }</span>
</div>
<div class="flex justify-between text-xs text-muted-foreground">
<span>{ model.FormatMoney(b.Spent) } spent</span>
<span>of { model.FormatMoney(b.Amount) }</span>
</div>
<div class="w-full bg-muted rounded-full h-2">
<div class={ "h-2 rounded-full transition-all", overviewProgressBarColor(b.Status) } style={ fmt.Sprintf("width: %.1f%%", pct) }></div>
</div>
</div>
}
}
</div>
}
</div>
}
templ overviewRecurringCard(data OverviewData) {
{{ upcoming := sortedActiveRecurring(data.UpcomingRecurring) }}
<div class="border rounded-lg p-4 bg-card text-card-foreground">
@overviewSectionHeader("Upcoming Recurring", "/app/spaces/"+data.Space.ID+"/recurring")
if len(upcoming) == 0 {
<p class="text-sm text-muted-foreground">No active recurring payments.</p>
} else {
<div class="divide-y">
for i, r := range upcoming {
if i < 5 {
<div class="py-2 flex justify-between items-start gap-2">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium truncate">{ r.Description }</p>
<p class="text-xs text-muted-foreground">
{ overviewFrequencyLabel(r.Frequency) } &middot; Next: { r.NextOccurrence.Format("Jan 02") }
</p>
</div>
if r.Type == model.ExpenseTypeExpense {
<p class="text-sm font-bold text-destructive shrink-0">
{ model.FormatMoney(r.Amount) }
</p>
} else {
<p class="text-sm font-bold text-green-500 shrink-0">
+{ model.FormatMoney(r.Amount) }
</p>
}
</div>
}
}
</div>
}
</div>
}
templ overviewTopExpensesCard(data OverviewData) {
<div class="border rounded-lg p-4 bg-card text-card-foreground">
@overviewSectionHeader("Top Expenses", "/app/spaces/"+data.Space.ID+"/expenses")
if data.Report == nil || len(data.Report.TopExpenses) == 0 {
<p class="text-sm text-muted-foreground">No expenses this month.</p>
} else {
<div class="divide-y">
for i, exp := range data.Report.TopExpenses {
if i < 5 {
<div class="py-2 flex justify-between items-start gap-2">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium truncate">{ exp.Description }</p>
<p class="text-xs text-muted-foreground">{ exp.Date.Format("Jan 02, 2006") }</p>
if len(exp.Tags) > 0 {
<div class="flex flex-wrap gap-1 mt-0.5">
for _, t := range exp.Tags {
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
{ t.Name }
}
}
</div>
}
</div>
<p class="text-sm font-bold text-destructive shrink-0">
{ model.FormatMoney(exp.Amount) }
</p>
</div>
}
}
</div>
}
</div>
}
templ overviewShoppingListsCard(data OverviewData) {
<div class="border rounded-lg p-4 bg-card text-card-foreground">
@overviewSectionHeader("Shopping Lists", "/app/spaces/"+data.Space.ID+"/lists")
if len(data.ShoppingLists) == 0 {
<p class="text-sm text-muted-foreground">No shopping lists yet.</p>
} else {
<div class="divide-y">
for i, card := range data.ShoppingLists {
if i < 3 {
<a href={ templ.SafeURL("/app/spaces/" + data.Space.ID + "/lists/" + card.List.ID) } class="py-2 flex justify-between items-center hover:bg-accent/50 -mx-1 px-1 rounded transition-colors">
<span class="text-sm font-medium">{ card.List.Name }</span>
{{ uc := uncheckedCount(card.Items) }}
if uc > 0 {
<span class="text-xs text-muted-foreground">{ fmt.Sprintf("%d unchecked", uc) }</span>
} else {
<span class="text-xs text-muted-foreground">All done</span>
}
</a>
}
}
</div>
}
</div>
}

View file

@ -1,45 +0,0 @@
package pages
import (
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/paymentmethod"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
templ SpacePaymentMethodsPage(space *model.Space, methods []*model.PaymentMethod) {
@layouts.Space("Payment Methods", space) {
<div class="space-y-4">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold">Payment Methods</h1>
@dialog.Dialog(dialog.Props{ID: "add-method-dialog"}) {
@dialog.Trigger() {
@button.Button() {
Add Method
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Add Payment Method
}
@dialog.Description() {
Add a credit or debit card to track how you pay for expenses.
}
}
@paymentmethod.CreateMethodForm(space.ID, "add-method-dialog")
}
}
</div>
<div id="methods-list" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
if len(methods) == 0 {
<p class="text-sm text-muted-foreground col-span-full">No payment methods yet. Add one to start tracking how you pay for expenses.</p>
}
for _, method := range methods {
@paymentmethod.MethodItem(space.ID, method)
}
</div>
</div>
}
}

View file

@ -1,47 +0,0 @@
package pages
import (
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/recurring"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
templ SpaceRecurringPage(space *model.Space, recs []*model.RecurringExpenseWithTagsAndMethod, tags []*model.Tag, methods []*model.PaymentMethod) {
@layouts.Space("Recurring", space) {
<div class="space-y-4">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold">Recurring Transactions</h1>
@dialog.Dialog(dialog.Props{ID: "add-recurring-dialog"}) {
@dialog.Trigger() {
@button.Button() {
Add Recurring
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Add Recurring Transaction
}
@dialog.Description() {
Set up a recurring expense or top-up that will auto-generate on schedule.
}
}
@recurring.AddRecurringForm(space.ID, tags, methods, "add-recurring-dialog")
}
}
</div>
<div class="border rounded-lg">
<div id="recurring-list" class="divide-y">
if len(recs) == 0 {
<p class="p-4 text-sm text-muted-foreground">No recurring transactions set up yet.</p>
}
for _, re := range recs {
@recurring.RecurringItem(space.ID, re, methods, tags)
}
</div>
</div>
</div>
}
}

View file

@ -1,236 +0,0 @@
package pages
import (
"fmt"
"time"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/service"
"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/chart"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
"github.com/shopspring/decimal"
)
var defaultChartColors = []string{
"#3b82f6", "#ef4444", "#22c55e", "#f59e0b", "#8b5cf6",
"#ec4899", "#06b6d4", "#f97316", "#14b8a6", "#6366f1",
}
func chartColor(i int, tagColor *string) string {
if tagColor != nil && *tagColor != "" {
return *tagColor
}
return defaultChartColors[i%len(defaultChartColors)]
}
templ SpaceReportsPage(space *model.Space, report *model.SpendingReport, presets []service.DateRange, activeRange string) {
@layouts.Space("Reports", space) {
@chart.Script()
<div class="space-y-4">
<h1 class="text-2xl font-bold">Reports</h1>
<div id="report-content">
@ReportCharts(space.ID, report, presets[0].From, presets[0].To, presets, activeRange)
</div>
</div>
}
}
templ ReportCharts(spaceID string, report *model.SpendingReport, from, to time.Time, presets []service.DateRange, activeRange string) {
// Date range selector
<div class="flex flex-wrap gap-2 items-center mb-4">
for _, p := range presets {
if p.Key == activeRange {
@button.Button(button.Props{
Size: button.SizeSm,
Attributes: templ.Attributes{
"hx-get": fmt.Sprintf("/app/spaces/%s/components/report-charts?range=%s", spaceID, p.Key),
"hx-target": "#report-content",
"hx-swap": "innerHTML",
},
}) {
{ p.Label }
}
} else {
@button.Button(button.Props{
Variant: button.VariantOutline,
Size: button.SizeSm,
Attributes: templ.Attributes{
"hx-get": fmt.Sprintf("/app/spaces/%s/components/report-charts?range=%s", spaceID, p.Key),
"hx-target": "#report-content",
"hx-swap": "innerHTML",
},
}) {
{ p.Label }
}
}
}
</div>
<div class="grid gap-4 md:grid-cols-2 overflow-hidden">
// Income vs Expenses Summary
<div class="border rounded-lg p-4 bg-card text-card-foreground space-y-2 min-w-0">
<h3 class="font-semibold">Income vs Expenses</h3>
<div class="space-y-1">
<div class="flex justify-between">
<span class="text-green-500 font-medium">Income</span>
<span class="font-bold text-green-500">{ model.FormatMoney(report.TotalIncome) }</span>
</div>
<div class="flex justify-between">
<span class="text-destructive font-medium">Expenses</span>
<span class="font-bold text-destructive">{ model.FormatMoney(report.TotalExpenses) }</span>
</div>
<hr class="border-border"/>
<div class="flex justify-between">
<span class="font-medium">Net</span>
<span class={ "font-bold", templ.KV("text-green-500", report.NetBalance.GreaterThanOrEqual(decimal.Zero)), templ.KV("text-destructive", report.NetBalance.LessThan(decimal.Zero)) }>
{ model.FormatMoney(report.NetBalance) }
</span>
</div>
</div>
</div>
// Spending by Tag (Doughnut chart)
if len(report.ByTag) > 0 {
<div class="border rounded-lg p-4 bg-card text-card-foreground min-w-0 overflow-hidden">
<h3 class="font-semibold mb-2">Spending by Category</h3>
{{
tagLabels := make([]string, len(report.ByTag))
tagData := make([]float64, len(report.ByTag))
tagColors := make([]string, len(report.ByTag))
for i, t := range report.ByTag {
tagLabels[i] = t.TagName
tagData[i] = t.TotalAmount.InexactFloat64()
tagColors[i] = chartColor(i, t.TagColor)
}
}}
@chart.Chart(chart.Props{
Variant: chart.VariantDoughnut,
ShowLegend: true,
Data: chart.Data{
Labels: tagLabels,
Datasets: []chart.Dataset{
{
Label: "Spending",
Data: tagData,
BackgroundColor: tagColors,
},
},
},
})
</div>
} else {
<div class="border rounded-lg p-4 bg-card text-card-foreground min-w-0">
<h3 class="font-semibold mb-2">Spending by Category</h3>
<p class="text-sm text-muted-foreground">No tagged expenses in this period.</p>
</div>
}
// Spending by Payment Method (Doughnut chart)
if len(report.ByPaymentMethod) > 0 {
<div class="border rounded-lg p-4 bg-card text-card-foreground min-w-0 overflow-hidden">
<h3 class="font-semibold mb-2">Spending by Payment Method</h3>
{{
pmLabels := make([]string, len(report.ByPaymentMethod))
pmData := make([]float64, len(report.ByPaymentMethod))
pmColors := make([]string, len(report.ByPaymentMethod))
for i, pm := range report.ByPaymentMethod {
pmLabels[i] = pm.PaymentMethodName
pmData[i] = pm.TotalAmount.InexactFloat64()
pmColors[i] = defaultChartColors[i%len(defaultChartColors)]
}
}}
@chart.Chart(chart.Props{
Variant: chart.VariantDoughnut,
ShowLegend: true,
Data: chart.Data{
Labels: pmLabels,
Datasets: []chart.Dataset{
{
Label: "Spending",
Data: pmData,
BackgroundColor: pmColors,
},
},
},
})
</div>
} else {
<div class="border rounded-lg p-4 bg-card text-card-foreground min-w-0">
<h3 class="font-semibold mb-2">Spending by Payment Method</h3>
<p class="text-sm text-muted-foreground">No payment method data in this period.</p>
</div>
}
// Spending Over Time (Bar chart)
if len(report.DailySpending) > 0 || len(report.MonthlySpending) > 0 {
<div class="border rounded-lg p-4 bg-card text-card-foreground min-w-0 overflow-hidden">
<h3 class="font-semibold mb-2">Spending Over Time</h3>
{{
days := to.Sub(from).Hours() / 24
var timeLabels []string
var timeData []float64
if days <= 31 {
for _, d := range report.DailySpending {
timeLabels = append(timeLabels, d.Date.Format("Jan 02"))
timeData = append(timeData, d.Total.InexactFloat64())
}
} else {
for _, m := range report.MonthlySpending {
timeLabels = append(timeLabels, m.Month)
timeData = append(timeData, m.Total.InexactFloat64())
}
}
}}
@chart.Chart(chart.Props{
Variant: chart.VariantBar,
ShowYAxis: true,
ShowXAxis: true,
ShowXLabels: true,
ShowYLabels: true,
Data: chart.Data{
Labels: timeLabels,
Datasets: []chart.Dataset{
{
Label: "Spending",
Data: timeData,
BackgroundColor: "#3b82f6",
},
},
},
})
</div>
} else {
<div class="border rounded-lg p-4 bg-card text-card-foreground min-w-0">
<h3 class="font-semibold mb-2">Spending Over Time</h3>
<p class="text-sm text-muted-foreground">No expenses in this period.</p>
</div>
}
// Top 10 Expenses
<div class="border rounded-lg p-4 bg-card text-card-foreground min-w-0 overflow-hidden">
<h3 class="font-semibold mb-2">Top Expenses</h3>
if len(report.TopExpenses) == 0 {
<p class="text-sm text-muted-foreground">No expenses in this period.</p>
} else {
<div class="divide-y">
for _, exp := range report.TopExpenses {
<div class="py-2 flex justify-between items-start gap-2">
<div class="min-w-0 flex-1">
<p class="font-medium text-sm truncate">{ exp.Description }</p>
<p class="text-xs text-muted-foreground">{ exp.Date.Format("Jan 02, 2006") }</p>
if len(exp.Tags) > 0 {
<div class="flex flex-wrap gap-1 mt-0.5">
for _, t := range exp.Tags {
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
{ t.Name }
}
}
</div>
}
</div>
<p class="font-bold text-destructive text-sm shrink-0">
{ model.FormatMoney(exp.Amount) }
</p>
</div>
}
</div>
}
</div>
</div>
}

View file

@ -1,402 +0,0 @@
package pages
import (
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/timezone"
"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/label"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/selectbox"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
templ SpaceSettingsPage(space *model.Space, members []*model.SpaceMemberWithProfile, pendingInvites []*model.SpaceInvitation, isOwner bool, currentUserID string) {
@layouts.Space("Settings", space) {
<div class="space-y-6 max-w-2xl">
// Space Name Section
@card.Card() {
@card.Header() {
@card.Title() {
Space Name
}
@card.Description() {
if isOwner {
Update the name of this space.
} else {
The name of this space.
}
}
}
@card.Content() {
if isOwner {
<form
hx-patch={ "/app/spaces/" + space.ID + "/settings/name" }
hx-swap="none"
class="flex gap-2 items-start"
>
@csrf.Token()
@input.Input(input.Props{
Name: "name",
Value: space.Name,
Attributes: templ.Attributes{
"autocomplete": "off",
"required": true,
},
})
@button.Submit() {
Save
}
</form>
} else {
<p class="text-sm">{ space.Name }</p>
}
}
}
// Timezone Section
@card.Card() {
@card.Header() {
@card.Title() {
Timezone
}
@card.Description() {
if isOwner {
Set a timezone for this space. Recurring expenses and reports will use this timezone.
} else {
The timezone used for recurring expenses and reports in this space.
}
}
}
@card.Content() {
if isOwner {
<form
hx-patch={ "/app/spaces/" + space.ID + "/settings/timezone" }
hx-swap="none"
class="space-y-4"
>
@csrf.Token()
@form.Item() {
@label.Label(label.Props{
For: "timezone",
Class: "block mb-2",
}) {
Timezone
}
@selectbox.SelectBox(selectbox.Props{ID: "space-timezone-select"}) {
@selectbox.Trigger(selectbox.TriggerProps{Name: "timezone"}) {
@selectbox.Value(selectbox.ValueProps{Placeholder: "Select timezone"})
}
@selectbox.Content(selectbox.ContentProps{SearchPlaceholder: "Search timezones..."}) {
for _, tz := range timezone.CommonTimezones() {
@selectbox.Item(selectbox.ItemProps{Value: tz.Value, Selected: space.Timezone != nil && tz.Value == *space.Timezone}) {
{ tz.Label }
}
}
}
}
}
@button.Submit() {
Save Timezone
}
</form>
} else {
if space.Timezone != nil && *space.Timezone != "" {
<p class="text-sm">{ *space.Timezone }</p>
} else {
<p class="text-sm text-muted-foreground">Not set (uses your timezone)</p>
}
}
}
}
// Members Section
@card.Card() {
@card.Header() {
@card.Title() {
<div class="flex items-center gap-2">
@icon.Users(icon.Props{Class: "size-5"})
Members
</div>
}
@card.Description() {
People who have access to this space.
}
}
@card.Content() {
<div class="divide-y" id="members-list">
for _, member := range members {
@MemberRow(space.ID, member, isOwner, currentUserID)
}
</div>
}
}
// Invitations Section (owner only)
if isOwner {
@card.Card() {
@card.Header() {
@card.Title() {
<div class="flex items-center gap-2">
@icon.Mail(icon.Props{Class: "size-5"})
Invitations
</div>
}
@card.Description() {
Invite new members and manage pending invitations.
}
}
@card.Content() {
<div class="space-y-4">
<form
hx-post={ "/app/spaces/" + space.ID + "/invites" }
hx-swap="none"
_="on htmx:afterOnLoad if event.detail.xhr.status == 200 reset() me then send refreshInvites to #pending-invites"
class="flex gap-2 items-start"
>
@csrf.Token()
@input.Input(input.Props{
Name: "email",
Placeholder: "Email address...",
Attributes: templ.Attributes{
"type": "email",
"autocomplete": "off",
"required": true,
},
})
@button.Submit() {
@icon.UserPlus(icon.Props{Class: "size-4"})
Invite
}
</form>
<div
id="pending-invites"
hx-get={ "/app/spaces/" + space.ID + "/settings/invites" }
hx-trigger="refreshInvites from:body"
hx-swap="innerHTML"
>
if len(pendingInvites) > 0 {
<h4 class="text-sm font-medium text-muted-foreground mb-2">Pending invitations</h4>
<div class="divide-y">
for _, invite := range pendingInvites {
@PendingInviteRow(space.ID, invite)
}
</div>
} else {
<p class="text-sm text-muted-foreground">No pending invitations.</p>
}
</div>
</div>
}
}
}
// Danger Zone (owner only)
if isOwner {
@card.Card() {
@card.Header() {
@card.Title() {
<div class="flex items-center gap-2 text-destructive">
@icon.TriangleAlert(icon.Props{Class: "size-5"})
Danger Zone
</div>
}
@card.Description() {
Irreversible and destructive actions.
}
}
@card.Content() {
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">Delete this space</p>
<p class="text-sm text-muted-foreground">Once deleted, all data in this space will be permanently removed.</p>
</div>
{{ deleteDialogID := "delete-space-dialog-" + space.ID }}
@dialog.Dialog(dialog.Props{ID: deleteDialogID, DisableClickAway: true}) {
@dialog.Trigger() {
@button.Button(button.Props{
Variant: button.VariantDestructive,
Type: button.TypeButton,
}) {
Delete Space
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Delete space
}
@dialog.Description() {
This action is permanent and cannot be undone. All data including expenses, budgets, shopping lists, and members will be permanently deleted.
}
}
<form
hx-delete={ "/app/spaces/" + space.ID }
hx-swap="none"
class="space-y-4"
>
@csrf.Token()
<div class="space-y-2">
@label.Label(label.Props{For: "confirmation_name"}) {
Type <span class="font-semibold">{ space.Name }</span> to confirm
}
@input.Input(input.Props{
Name: "confirmation_name",
Placeholder: space.Name,
Attributes: templ.Attributes{
"id": "confirmation_name",
"autocomplete": "off",
},
})
</div>
<div class="flex justify-end gap-2">
@dialog.Close() {
@button.Button(button.Props{
Variant: button.VariantOutline,
Type: button.TypeButton,
}) {
Cancel
}
}
@button.Button(button.Props{
Variant: button.VariantDestructive,
Type: button.TypeSubmit,
Attributes: templ.Attributes{
"id": "delete-space-confirm",
},
}) {
Delete Space
}
</div>
</form>
}
}
</div>
}
}
}
</div>
@dialog.Script()
}
}
templ MemberRow(spaceID string, member *model.SpaceMemberWithProfile, isOwner bool, currentUserID string) {
<div id={ "member-" + member.UserID } class="flex items-center justify-between py-3">
<div class="flex items-center gap-3">
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm font-medium">
{ string([]rune(member.Name)[0]) }
</div>
<div>
<p class="text-sm font-medium">{ member.Name }</p>
<p class="text-xs text-muted-foreground">{ member.Email }</p>
</div>
</div>
<div class="flex items-center gap-2">
if member.Role == model.RoleOwner {
@badge.Badge(badge.Props{Variant: badge.VariantDefault}) {
@icon.Crown(icon.Props{Class: "size-3"})
Owner
}
} else {
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
Member
}
}
if isOwner && member.UserID != currentUserID && member.Role != model.RoleOwner {
{{ dialogID := "remove-member-dialog-" + member.UserID }}
@dialog.Dialog(dialog.Props{ID: dialogID}) {
@dialog.Trigger() {
@button.Button(button.Props{
Variant: button.VariantGhost,
Size: button.SizeIcon,
Type: button.TypeButton,
}) {
@icon.UserMinus(icon.Props{Class: "size-4 text-destructive"})
}
}
@dialog.Content() {
@dialog.Header() {
@dialog.Title() {
Remove member
}
@dialog.Description() {
Are you sure you want to remove { member.Name } from this space? They will lose access immediately.
}
}
@dialog.Footer() {
@dialog.Close() {
@button.Button(button.Props{
Variant: button.VariantOutline,
Type: button.TypeButton,
}) {
Cancel
}
}
@dialog.Close() {
@button.Button(button.Props{
Variant: button.VariantDestructive,
Type: button.TypeButton,
Attributes: templ.Attributes{
"hx-delete": "/app/spaces/" + spaceID + "/members/" + member.UserID,
"hx-target": "#member-" + member.UserID,
"hx-swap": "outerHTML",
"hx-headers": `{"X-CSRF-Token": "` + ctxkeys.CSRFToken(ctx) + `"}`,
},
}) {
Remove
}
}
}
}
}
}
</div>
</div>
}
templ PendingInviteRow(spaceID string, invite *model.SpaceInvitation) {
<div id={ "invite-" + invite.Token } class="flex items-center justify-between py-3">
<div class="flex items-center gap-3">
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-muted text-sm">
@icon.Mail(icon.Props{Class: "size-4 text-muted-foreground"})
</div>
<div>
<p class="text-sm font-medium">{ invite.Email }</p>
<p class="text-xs text-muted-foreground">Sent { invite.CreatedAt.Format("Jan 02, 2006") }</p>
</div>
</div>
<div class="flex items-center gap-2">
@badge.Badge(badge.Props{Variant: badge.VariantOutline}) {
Pending
}
@button.Button(button.Props{
Variant: button.VariantGhost,
Size: button.SizeIcon,
Type: button.TypeButton,
Attributes: templ.Attributes{
"hx-delete": "/app/spaces/" + spaceID + "/invites/" + invite.Token,
"hx-target": "#invite-" + invite.Token,
"hx-swap": "outerHTML",
"hx-headers": `{"X-CSRF-Token": "` + ctxkeys.CSRFToken(ctx) + `"}`,
},
}) {
@icon.X(icon.Props{Class: "size-4 text-destructive"})
}
</div>
</div>
}
templ PendingInvitesList(spaceID string, pendingInvites []*model.SpaceInvitation) {
if len(pendingInvites) > 0 {
<h4 class="text-sm font-medium text-muted-foreground mb-2">Pending invitations</h4>
<div class="divide-y">
for _, invite := range pendingInvites {
@PendingInviteRow(spaceID, invite)
}
</div>
} else {
<p class="text-sm text-muted-foreground">No pending invitations.</p>
}
}

View file

@ -1,47 +0,0 @@
package pages
import (
"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/input"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/tag"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
templ SpaceTagsPage(space *model.Space, tags []*model.Tag) {
@layouts.Space("Tags", space) {
<div class="space-y-4">
<div class="flex justify-between items-center">
<h1 class="text-2xl font-bold">Tags</h1>
</div>
<form
hx-post={ "/app/spaces/" + space.ID + "/tags" }
hx-target="#tags-container"
hx-swap="beforeend"
_="on htmx:afterOnLoad if event.detail.xhr.status == 200 reset() me"
class="flex gap-2 items-start"
>
@csrf.Token()
@input.Input(input.Props{
Name: "name",
Placeholder: "New tag name...",
Attributes: templ.Attributes{
"autocomplete": "off",
},
})
@button.Submit() {
Create
}
</form>
<div
id="tags-container"
class="flex flex-wrap gap-2"
>
for _, t := range tags {
@tag.Tag(t)
}
</div>
</div>
}
}