chore: massive reset
This commit is contained in:
parent
c7ee3da8f2
commit
df164ab0f4
96 changed files with 198 additions and 15405 deletions
|
|
@ -1,37 +0,0 @@
|
|||
package dialogs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/dialog"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/button"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/expense"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
)
|
||||
|
||||
templ AddTransaction(space *model.Space, tags []*model.Tag, listsWithItems []model.ListWithUncheckedItems, methods []*model.PaymentMethod) {
|
||||
@dialog.Dialog(dialog.Props{ID: "add-transaction-dialog"}) {
|
||||
@dialog.Trigger() {
|
||||
@button.Button() {
|
||||
Add Transaction
|
||||
}
|
||||
}
|
||||
@dialog.Content() {
|
||||
@dialog.Header() {
|
||||
@dialog.Title() {
|
||||
Add Transaction
|
||||
}
|
||||
@dialog.Description() {
|
||||
Add a new expense or top-up to your space.
|
||||
}
|
||||
}
|
||||
@expense.AddExpenseForm(expense.AddExpenseFormProps{
|
||||
Space: space,
|
||||
Tags: tags,
|
||||
ListsWithItems: listsWithItems,
|
||||
PaymentMethods: methods,
|
||||
DialogID: "add-transaction-dialog",
|
||||
RedirectURL: fmt.Sprintf("/app/spaces/%s/expenses?created=true", space.ID),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,367 +0,0 @@
|
|||
package expense
|
||||
|
||||
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/checkbox"
|
||||
"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/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/paymentmethod"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/radio"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/tagcombobox"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
type AddExpenseFormProps struct {
|
||||
Space *model.Space
|
||||
Tags []*model.Tag
|
||||
ListsWithItems []model.ListWithUncheckedItems
|
||||
PaymentMethods []*model.PaymentMethod
|
||||
DialogID string // which dialog to close on success
|
||||
RedirectURL string // when set, server returns HX-Redirect instead of inline swap
|
||||
}
|
||||
|
||||
func (p AddExpenseFormProps) formAttrs() templ.Attributes {
|
||||
attrs := templ.Attributes{
|
||||
"hx-post": "/app/spaces/" + p.Space.ID + "/expenses",
|
||||
}
|
||||
if p.RedirectURL != "" {
|
||||
attrs["_"] = "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + p.DialogID + "') end"
|
||||
} else {
|
||||
attrs["hx-target"] = "#expenses-list-wrapper"
|
||||
attrs["hx-swap"] = "innerHTML"
|
||||
attrs["_"] = "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + p.DialogID + "') then reset() me then show #item-selector-section end"
|
||||
}
|
||||
return attrs
|
||||
}
|
||||
|
||||
templ AddExpenseForm(props AddExpenseFormProps) {
|
||||
<form
|
||||
class="space-y-4"
|
||||
{ props.formAttrs()... }
|
||||
>
|
||||
@csrf.Token()
|
||||
if props.RedirectURL != "" {
|
||||
<input type="hidden" name="redirect" value={ props.RedirectURL }/>
|
||||
}
|
||||
// Type
|
||||
<div class="flex gap-4">
|
||||
<div class="flex items-start gap-3">
|
||||
@radio.Radio(radio.Props{
|
||||
ID: "expense-type-expense",
|
||||
Name: "type",
|
||||
Value: "expense",
|
||||
Checked: true,
|
||||
Attributes: templ.Attributes{
|
||||
"_": "on click show #item-selector-section",
|
||||
},
|
||||
})
|
||||
<div class="grid gap-2">
|
||||
@label.Label(label.Props{
|
||||
For: "expense-type-expense",
|
||||
}) {
|
||||
Expense
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
@radio.Radio(radio.Props{
|
||||
ID: "expense-type-topup",
|
||||
Name: "type",
|
||||
Value: "topup",
|
||||
Attributes: templ.Attributes{
|
||||
"_": "on click hide #item-selector-section",
|
||||
},
|
||||
})
|
||||
<div class="grid gap-2">
|
||||
@label.Label(label.Props{
|
||||
For: "expense-type-topup",
|
||||
}) {
|
||||
Top-up
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
// Description
|
||||
<div>
|
||||
@label.Label(label.Props{
|
||||
For: "description",
|
||||
}) {
|
||||
Description
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "description",
|
||||
ID: "description",
|
||||
Attributes: templ.Attributes{"required": "true"},
|
||||
})
|
||||
</div>
|
||||
// Amount
|
||||
<div>
|
||||
@label.Label(label.Props{
|
||||
For: "amount",
|
||||
}) {
|
||||
Amount
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "amount",
|
||||
ID: "amount",
|
||||
Type: "number",
|
||||
Attributes: templ.Attributes{"step": "0.01", "required": "true"},
|
||||
})
|
||||
</div>
|
||||
// Date
|
||||
<div>
|
||||
@label.Label(label.Props{
|
||||
For: "date",
|
||||
}) {
|
||||
Date
|
||||
}
|
||||
@datepicker.DatePicker(datepicker.Props{
|
||||
ID: "date",
|
||||
Name: "date",
|
||||
Clearable: true,
|
||||
Required: true,
|
||||
})
|
||||
</div>
|
||||
// Tags
|
||||
<div>
|
||||
@label.Label(label.Props{For: "new-expense-tags"}) {
|
||||
Tags (Optional)
|
||||
}
|
||||
@tagcombobox.TagCombobox(tagcombobox.Props{
|
||||
ID: "new-expense-tags",
|
||||
Name: "tags",
|
||||
Tags: props.Tags,
|
||||
Placeholder: "Search or create tags...",
|
||||
})
|
||||
</div>
|
||||
// Payment Method
|
||||
@paymentmethod.MethodSelector(props.PaymentMethods, nil)
|
||||
// Shopping list items selector
|
||||
@ItemSelectorSection(props.ListsWithItems, false)
|
||||
<div class="flex justify-end">
|
||||
@button.Submit() {
|
||||
Save
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
templ EditExpenseForm(spaceID string, exp *model.ExpenseWithTagsAndMethod, methods []*model.PaymentMethod, tags []*model.Tag) {
|
||||
{{ editDialogID := "edit-expense-" + exp.ID }}
|
||||
{{ tagValues := make([]string, len(exp.Tags)) }}
|
||||
for i, t := range exp.Tags {
|
||||
{{ tagValues[i] = t.Name }}
|
||||
}
|
||||
<form
|
||||
hx-patch={ fmt.Sprintf("/app/spaces/%s/expenses/%s", spaceID, exp.ID) }
|
||||
hx-target={ "#expense-" + exp.ID }
|
||||
hx-swap="outerHTML"
|
||||
_={ "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + editDialogID + "') end" }
|
||||
class="space-y-4"
|
||||
>
|
||||
@csrf.Token()
|
||||
// Type
|
||||
<div class="flex gap-4">
|
||||
<div class="flex items-start gap-3">
|
||||
@radio.Radio(radio.Props{
|
||||
ID: "edit-type-expense-" + exp.ID,
|
||||
Name: "type",
|
||||
Value: "expense",
|
||||
Checked: exp.Type == model.ExpenseTypeExpense,
|
||||
})
|
||||
<div class="grid gap-2">
|
||||
@label.Label(label.Props{For: "edit-type-expense-" + exp.ID}) {
|
||||
Expense
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
@radio.Radio(radio.Props{
|
||||
ID: "edit-type-topup-" + exp.ID,
|
||||
Name: "type",
|
||||
Value: "topup",
|
||||
Checked: exp.Type == model.ExpenseTypeTopup,
|
||||
})
|
||||
<div class="grid gap-2">
|
||||
@label.Label(label.Props{For: "edit-type-topup-" + exp.ID}) {
|
||||
Top-up
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
// Description
|
||||
<div>
|
||||
@label.Label(label.Props{For: "edit-description-" + exp.ID}) {
|
||||
Description
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "description",
|
||||
ID: "edit-description-" + exp.ID,
|
||||
Value: exp.Description,
|
||||
Attributes: templ.Attributes{"required": "true"},
|
||||
})
|
||||
</div>
|
||||
// Amount
|
||||
<div>
|
||||
@label.Label(label.Props{For: "edit-amount-" + exp.ID}) {
|
||||
Amount
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "amount",
|
||||
ID: "edit-amount-" + exp.ID,
|
||||
Type: "number",
|
||||
Value: model.FormatDecimal(exp.Amount),
|
||||
Attributes: templ.Attributes{"step": "0.01", "required": "true"},
|
||||
})
|
||||
</div>
|
||||
// Date
|
||||
<div>
|
||||
@label.Label(label.Props{For: "edit-date-" + exp.ID}) {
|
||||
Date
|
||||
}
|
||||
@datepicker.DatePicker(datepicker.Props{
|
||||
ID: "edit-date-" + exp.ID,
|
||||
Name: "date",
|
||||
Value: exp.Date,
|
||||
Attributes: templ.Attributes{"required": "true"},
|
||||
})
|
||||
</div>
|
||||
// Tags
|
||||
<div>
|
||||
@label.Label(label.Props{For: "edit-tags-" + exp.ID}) {
|
||||
Tags (Optional)
|
||||
}
|
||||
@tagcombobox.TagCombobox(tagcombobox.Props{
|
||||
ID: "edit-tags-" + exp.ID,
|
||||
Name: "tags",
|
||||
Value: tagValues,
|
||||
Tags: tags,
|
||||
Placeholder: "Search or create tags...",
|
||||
})
|
||||
</div>
|
||||
// Payment Method
|
||||
@paymentmethod.MethodSelector(methods, exp.PaymentMethodID)
|
||||
<div class="flex justify-end">
|
||||
@button.Submit() {
|
||||
Save
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
templ ItemSelectorSection(listsWithItems []model.ListWithUncheckedItems, oob bool) {
|
||||
<div
|
||||
id="item-selector-section"
|
||||
if oob {
|
||||
hx-swap-oob="true"
|
||||
}
|
||||
>
|
||||
@label.Label(label.Props{}) {
|
||||
Link Shopping List Items
|
||||
}
|
||||
if len(listsWithItems) == 0 {
|
||||
<p class="text-sm text-muted-foreground">No unchecked items available.</p>
|
||||
} else {
|
||||
<div class="max-h-48 overflow-y-auto border rounded-md p-2 space-y-2">
|
||||
for i, lwi := range listsWithItems {
|
||||
{{ toggleID := "toggle-list-" + lwi.List.ID }}
|
||||
{{ itemsID := "items-" + lwi.List.ID }}
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
@checkbox.Checkbox(checkbox.Props{
|
||||
ID: "select-all-" + lwi.List.ID,
|
||||
Attributes: templ.Attributes{
|
||||
"_": "on change repeat for cb in <input[name='item_ids']/> in #" + itemsID + " set cb.checked to my.checked end",
|
||||
},
|
||||
})
|
||||
@button.Button(button.Props{
|
||||
ID: toggleID,
|
||||
Variant: button.VariantGhost,
|
||||
Class: "flex-1 h-auto p-0 justify-start gap-1 text-sm font-medium select-none",
|
||||
Attributes: templ.Attributes{
|
||||
"_": "on click toggle .hidden on #" + itemsID + " then toggle .rotate-90 on <svg/> in me",
|
||||
},
|
||||
}) {
|
||||
@icon.ChevronRight(icon.Props{Size: 14})
|
||||
{ lwi.List.Name }
|
||||
<span class="text-muted-foreground">
|
||||
({ strconv.Itoa(len(lwi.Items)) })
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div id={ itemsID } class="hidden pl-6 space-y-1">
|
||||
for _, item := range lwi.Items {
|
||||
<div class="flex items-center gap-2">
|
||||
@checkbox.Checkbox(checkbox.Props{
|
||||
ID: "item-cb-" + item.ID,
|
||||
Name: "item_ids",
|
||||
Value: item.ID,
|
||||
})
|
||||
<label for={ "item-cb-" + item.ID } class="text-sm cursor-pointer select-none">
|
||||
{ item.Name }
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
if i < len(listsWithItems) - 1 {
|
||||
<hr class="border-border"/>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
// Post-action radio group
|
||||
<div class="mt-2 space-y-1">
|
||||
<p class="text-sm text-muted-foreground">After linking items:</p>
|
||||
<div class="flex gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
@radio.Radio(radio.Props{
|
||||
ID: "item-action-check",
|
||||
Name: "item_action",
|
||||
Value: "check",
|
||||
Checked: true,
|
||||
})
|
||||
@label.Label(label.Props{For: "item-action-check"}) {
|
||||
Mark as checked
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@radio.Radio(radio.Props{
|
||||
ID: "item-action-delete",
|
||||
Name: "item_action",
|
||||
Value: "delete",
|
||||
})
|
||||
@label.Label(label.Props{For: "item-action-delete"}) {
|
||||
Delete from list
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ BalanceCard(spaceID string, balance decimal.Decimal, allocated decimal.Decimal, oob bool) {
|
||||
<div
|
||||
id="balance-card"
|
||||
class="border rounded-lg p-4 bg-card text-card-foreground"
|
||||
if oob {
|
||||
hx-swap-oob="true"
|
||||
}
|
||||
>
|
||||
<h2 class="text-lg font-semibold">Current Balance</h2>
|
||||
<p class={ "text-3xl font-bold", templ.KV("text-destructive", balance.LessThan(decimal.Zero)) }>
|
||||
{ model.FormatMoney(balance) }
|
||||
if allocated.GreaterThan(decimal.Zero) {
|
||||
<span class="text-base font-normal text-muted-foreground">
|
||||
({ model.FormatMoney(allocated) } in accounts)
|
||||
</span>
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
|
@ -1,385 +0,0 @@
|
|||
package moneyaccount
|
||||
|
||||
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/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"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/pagination"
|
||||
"github.com/shopspring/decimal"
|
||||
)
|
||||
|
||||
templ BalanceSummaryCard(spaceID string, totalBalance decimal.Decimal, availableBalance decimal.Decimal, oob bool) {
|
||||
<div
|
||||
id="accounts-balance-summary"
|
||||
class="border rounded-lg p-4 bg-card text-card-foreground"
|
||||
if oob {
|
||||
hx-swap-oob="true"
|
||||
}
|
||||
>
|
||||
<h2 class="text-lg font-semibold mb-2">Balance Summary</h2>
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">Total Balance</p>
|
||||
<p class={ "text-xl font-bold", templ.KV("text-destructive", totalBalance.LessThan(decimal.Zero)) }>
|
||||
{ model.FormatMoney(totalBalance) }
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">Allocated</p>
|
||||
<p class="text-xl font-bold">
|
||||
{ model.FormatMoney(totalBalance.Sub(availableBalance)) }
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-muted-foreground">Available</p>
|
||||
<p class={ "text-xl font-bold", templ.KV("text-destructive", availableBalance.LessThan(decimal.Zero)) }>
|
||||
{ model.FormatMoney(availableBalance) }
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ AccountCard(spaceID string, acct *model.MoneyAccountWithBalance, oob ...bool) {
|
||||
{{ editDialogID := "edit-account-" + acct.ID }}
|
||||
{{ delDialogID := "del-account-" + acct.ID }}
|
||||
{{ depositDialogID := "deposit-" + acct.ID }}
|
||||
{{ withdrawDialogID := "withdraw-" + acct.ID }}
|
||||
<div
|
||||
id={ "account-card-" + acct.ID }
|
||||
class="border rounded-lg p-4 bg-card text-card-foreground"
|
||||
if len(oob) > 0 && oob[0] {
|
||||
hx-swap-oob="true"
|
||||
}
|
||||
>
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<div>
|
||||
<h3 class="font-semibold text-lg">{ acct.Name }</h3>
|
||||
<p class={ "text-2xl font-bold", templ.KV("text-destructive", acct.Balance.LessThan(decimal.Zero)) }>
|
||||
{ model.FormatMoney(acct.Balance) }
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
// Edit
|
||||
@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 Account
|
||||
}
|
||||
@dialog.Description() {
|
||||
Update the account name.
|
||||
}
|
||||
}
|
||||
@EditAccountForm(spaceID, &acct.MoneyAccount, editDialogID)
|
||||
}
|
||||
}
|
||||
// Delete
|
||||
@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 Account
|
||||
}
|
||||
@dialog.Description() {
|
||||
Are you sure you want to delete "{ acct.Name }"? All transfers will be removed.
|
||||
}
|
||||
}
|
||||
@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/accounts/%s", spaceID, acct.ID),
|
||||
"hx-target": "#account-card-" + acct.ID,
|
||||
"hx-swap": "delete",
|
||||
},
|
||||
}) {
|
||||
Delete
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
// Deposit
|
||||
@dialog.Dialog(dialog.Props{ID: depositDialogID}) {
|
||||
@dialog.Trigger() {
|
||||
@button.Button(button.Props{Variant: button.VariantOutline, Size: button.SizeSm}) {
|
||||
@icon.ArrowDownToLine(icon.Props{Size: 14})
|
||||
Deposit
|
||||
}
|
||||
}
|
||||
@dialog.Content() {
|
||||
@dialog.Header() {
|
||||
@dialog.Title() {
|
||||
Deposit to { acct.Name }
|
||||
}
|
||||
@dialog.Description() {
|
||||
Move money from your available balance into this account.
|
||||
}
|
||||
}
|
||||
@TransferForm(spaceID, acct.ID, model.TransferDirectionDeposit, depositDialogID)
|
||||
}
|
||||
}
|
||||
// Withdraw
|
||||
@dialog.Dialog(dialog.Props{ID: withdrawDialogID}) {
|
||||
@dialog.Trigger() {
|
||||
@button.Button(button.Props{Variant: button.VariantOutline, Size: button.SizeSm}) {
|
||||
@icon.ArrowUpFromLine(icon.Props{Size: 14})
|
||||
Withdraw
|
||||
}
|
||||
}
|
||||
@dialog.Content() {
|
||||
@dialog.Header() {
|
||||
@dialog.Title() {
|
||||
Withdraw from { acct.Name }
|
||||
}
|
||||
@dialog.Description() {
|
||||
Move money from this account back to your available balance.
|
||||
}
|
||||
}
|
||||
@TransferForm(spaceID, acct.ID, model.TransferDirectionWithdrawal, withdrawDialogID)
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ CreateAccountForm(spaceID string, dialogID string) {
|
||||
<form
|
||||
hx-post={ "/app/spaces/" + spaceID + "/accounts" }
|
||||
hx-target="#accounts-list"
|
||||
hx-swap="beforeend"
|
||||
_={ "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + dialogID + "') then reset() me end" }
|
||||
class="space-y-4"
|
||||
>
|
||||
@csrf.Token()
|
||||
<div>
|
||||
@label.Label(label.Props{For: "account-name"}) {
|
||||
Account Name
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "name",
|
||||
ID: "account-name",
|
||||
Attributes: templ.Attributes{"required": "true", "placeholder": "e.g. Savings, Emergency Fund"},
|
||||
})
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
@button.Submit() {
|
||||
Create
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
templ EditAccountForm(spaceID string, acct *model.MoneyAccount, dialogID string) {
|
||||
<form
|
||||
hx-patch={ fmt.Sprintf("/app/spaces/%s/accounts/%s", spaceID, acct.ID) }
|
||||
hx-target={ "#account-card-" + acct.ID }
|
||||
hx-swap="outerHTML"
|
||||
_={ "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + dialogID + "') end" }
|
||||
class="space-y-4"
|
||||
>
|
||||
@csrf.Token()
|
||||
<div>
|
||||
@label.Label(label.Props{For: "edit-account-name-" + acct.ID}) {
|
||||
Account Name
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "name",
|
||||
ID: "edit-account-name-" + acct.ID,
|
||||
Value: acct.Name,
|
||||
Attributes: templ.Attributes{"required": "true"},
|
||||
})
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
@button.Submit() {
|
||||
Save
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
templ TransferForm(spaceID string, accountID string, direction model.TransferDirection, dialogID string) {
|
||||
{{ errorID := "transfer-error-" + accountID + "-" + string(direction) }}
|
||||
<form
|
||||
hx-post={ fmt.Sprintf("/app/spaces/%s/accounts/%s/transfers", spaceID, accountID) }
|
||||
hx-target={ "#" + errorID }
|
||||
hx-swap="innerHTML"
|
||||
_={ "on transferSuccess from body call window.tui.dialog.close('" + dialogID + "') then reset() me end" }
|
||||
class="space-y-4"
|
||||
>
|
||||
@csrf.Token()
|
||||
<input type="hidden" name="direction" value={ string(direction) }/>
|
||||
<div>
|
||||
@label.Label(label.Props{For: "transfer-amount-" + accountID + "-" + string(direction)}) {
|
||||
Amount
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "amount",
|
||||
ID: "transfer-amount-" + accountID + "-" + string(direction),
|
||||
Type: "number",
|
||||
Attributes: templ.Attributes{"step": "0.01", "required": "true", "min": "0.01"},
|
||||
})
|
||||
<p id={ errorID } class="text-sm text-destructive mt-1"></p>
|
||||
</div>
|
||||
<div>
|
||||
@label.Label(label.Props{For: "transfer-note-" + accountID + "-" + string(direction)}) {
|
||||
Note (optional)
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "note",
|
||||
ID: "transfer-note-" + accountID + "-" + string(direction),
|
||||
Attributes: templ.Attributes{"placeholder": "e.g. Monthly savings"},
|
||||
})
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
@button.Submit() {
|
||||
if direction == model.TransferDirectionDeposit {
|
||||
Deposit
|
||||
} else {
|
||||
Withdraw
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
templ TransferHistorySection(spaceID string, transfers []*model.AccountTransferWithAccount, currentPage, totalPages int) {
|
||||
<div class="space-y-4 mt-8">
|
||||
<h2 class="text-xl font-bold">Transfer History</h2>
|
||||
<div class="border rounded-lg">
|
||||
<div id="transfer-history-wrapper">
|
||||
@TransferHistoryContent(spaceID, transfers, currentPage, totalPages, false)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ TransferHistoryContent(spaceID string, transfers []*model.AccountTransferWithAccount, currentPage, totalPages int, oob bool) {
|
||||
<div
|
||||
if oob {
|
||||
id="transfer-history-wrapper"
|
||||
hx-swap-oob="innerHTML:#transfer-history-wrapper"
|
||||
}
|
||||
>
|
||||
<div class="divide-y">
|
||||
if len(transfers) == 0 {
|
||||
<p class="p-4 text-sm text-muted-foreground">No transfers recorded yet.</p>
|
||||
}
|
||||
for _, t := range transfers {
|
||||
@TransferHistoryItem(spaceID, t)
|
||||
}
|
||||
</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/transfer-history?page=%d", spaceID, currentPage-1),
|
||||
"hx-target": "#transfer-history-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/transfer-history?page=%d", spaceID, pg),
|
||||
"hx-target": "#transfer-history-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/transfer-history?page=%d", spaceID, currentPage+1),
|
||||
"hx-target": "#transfer-history-wrapper",
|
||||
"hx-swap": "innerHTML",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ TransferHistoryItem(spaceID string, t *model.AccountTransferWithAccount) {
|
||||
<div id={ "transfer-" + t.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">
|
||||
if t.Note != "" {
|
||||
{ t.Note }
|
||||
} else if t.Direction == model.TransferDirectionDeposit {
|
||||
Deposit
|
||||
} else {
|
||||
Withdrawal
|
||||
}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{ t.CreatedAt.Format("Jan 2, 2006") } · { t.AccountName }
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
if t.Direction == model.TransferDirectionDeposit {
|
||||
<span class="font-bold text-green-600 whitespace-nowrap">
|
||||
+{ model.FormatMoney(t.Amount) }
|
||||
</span>
|
||||
} else {
|
||||
<span class="font-bold text-destructive whitespace-nowrap">
|
||||
-{ model.FormatMoney(t.Amount) }
|
||||
</span>
|
||||
}
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantGhost,
|
||||
Size: button.SizeIcon,
|
||||
Class: "size-7",
|
||||
Attributes: templ.Attributes{
|
||||
"hx-delete": fmt.Sprintf("/app/spaces/%s/accounts/%s/transfers/%s", spaceID, t.AccountID, t.ID),
|
||||
"hx-target": "#transfer-" + t.ID,
|
||||
"hx-swap": "delete",
|
||||
"hx-confirm": "Delete this transfer?",
|
||||
},
|
||||
}) {
|
||||
@icon.Trash2(icon.Props{Size: 14})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
|
@ -1,269 +0,0 @@
|
|||
package paymentmethod
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"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/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/selectbox"
|
||||
)
|
||||
|
||||
func methodDisplay(m *model.PaymentMethod) string {
|
||||
upper := strings.ToUpper(string(m.Type))
|
||||
if m.LastFour != nil {
|
||||
return upper + " **** " + *m.LastFour
|
||||
}
|
||||
return upper
|
||||
}
|
||||
|
||||
templ MethodItem(spaceID string, method *model.PaymentMethod) {
|
||||
{{ editDialogID := "edit-method-" + method.ID }}
|
||||
{{ delDialogID := "del-method-" + method.ID }}
|
||||
<div id={ "method-item-" + method.ID } class="border rounded-lg p-4 bg-card text-card-foreground">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h3 class="font-semibold text-lg">{ method.Name }</h3>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{ methodDisplay(method) }
|
||||
</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 Payment Method
|
||||
}
|
||||
@dialog.Description() {
|
||||
Update the payment method details.
|
||||
}
|
||||
}
|
||||
@EditMethodForm(spaceID, method, editDialogID)
|
||||
}
|
||||
}
|
||||
@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 Payment Method
|
||||
}
|
||||
@dialog.Description() {
|
||||
Are you sure you want to delete "{ method.Name }"? Existing expenses will keep their data but will no longer show a payment method.
|
||||
}
|
||||
}
|
||||
@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/payment-methods/%s", spaceID, method.ID),
|
||||
"hx-target": "#method-item-" + method.ID,
|
||||
"hx-swap": "delete",
|
||||
},
|
||||
}) {
|
||||
Delete
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ CreateMethodForm(spaceID string, dialogID string) {
|
||||
<form
|
||||
hx-post={ "/app/spaces/" + spaceID + "/payment-methods" }
|
||||
hx-target="#methods-list"
|
||||
hx-swap="beforeend"
|
||||
_={ "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + dialogID + "') then reset() me end" }
|
||||
class="space-y-4"
|
||||
>
|
||||
@csrf.Token()
|
||||
<div>
|
||||
@label.Label(label.Props{For: "method-name"}) {
|
||||
Name
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "name",
|
||||
ID: "method-name",
|
||||
Attributes: templ.Attributes{"required": "true", "placeholder": "e.g. Chase Sapphire"},
|
||||
})
|
||||
</div>
|
||||
<div>
|
||||
@label.Label(label.Props{}) {
|
||||
Type
|
||||
}
|
||||
<div class="flex gap-4 mt-1">
|
||||
<div class="flex items-center gap-2">
|
||||
@radio.Radio(radio.Props{
|
||||
ID: "method-type-credit",
|
||||
Name: "type",
|
||||
Value: "credit",
|
||||
Checked: true,
|
||||
})
|
||||
@label.Label(label.Props{For: "method-type-credit"}) {
|
||||
Credit
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@radio.Radio(radio.Props{
|
||||
ID: "method-type-debit",
|
||||
Name: "type",
|
||||
Value: "debit",
|
||||
})
|
||||
@label.Label(label.Props{For: "method-type-debit"}) {
|
||||
Debit
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="last-four-group">
|
||||
@label.Label(label.Props{For: "method-last-four"}) {
|
||||
Last 4 Digits
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "last_four",
|
||||
ID: "method-last-four",
|
||||
Attributes: templ.Attributes{
|
||||
"required": "true",
|
||||
"maxlength": "4",
|
||||
"minlength": "4",
|
||||
"pattern": "[0-9]{4}",
|
||||
"placeholder": "1234",
|
||||
},
|
||||
})
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
@button.Submit() {
|
||||
Create
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
templ EditMethodForm(spaceID string, method *model.PaymentMethod, dialogID string) {
|
||||
{{ lastFourVal := "" }}
|
||||
if method.LastFour != nil {
|
||||
{{ lastFourVal = *method.LastFour }}
|
||||
}
|
||||
<form
|
||||
hx-patch={ fmt.Sprintf("/app/spaces/%s/payment-methods/%s", spaceID, method.ID) }
|
||||
hx-target={ "#method-item-" + method.ID }
|
||||
hx-swap="outerHTML"
|
||||
_={ "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + dialogID + "') end" }
|
||||
class="space-y-4"
|
||||
>
|
||||
@csrf.Token()
|
||||
<div>
|
||||
@label.Label(label.Props{For: "edit-method-name-" + method.ID}) {
|
||||
Name
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "name",
|
||||
ID: "edit-method-name-" + method.ID,
|
||||
Value: method.Name,
|
||||
Attributes: templ.Attributes{"required": "true"},
|
||||
})
|
||||
</div>
|
||||
<div>
|
||||
@label.Label(label.Props{}) {
|
||||
Type
|
||||
}
|
||||
<div class="flex gap-4 mt-1">
|
||||
<div class="flex items-center gap-2">
|
||||
@radio.Radio(radio.Props{
|
||||
ID: "edit-method-type-credit-" + method.ID,
|
||||
Name: "type",
|
||||
Value: "credit",
|
||||
Checked: method.Type == model.PaymentMethodTypeCredit,
|
||||
})
|
||||
@label.Label(label.Props{For: "edit-method-type-credit-" + method.ID}) {
|
||||
Credit
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
@radio.Radio(radio.Props{
|
||||
ID: "edit-method-type-debit-" + method.ID,
|
||||
Name: "type",
|
||||
Value: "debit",
|
||||
Checked: method.Type == model.PaymentMethodTypeDebit,
|
||||
})
|
||||
@label.Label(label.Props{For: "edit-method-type-debit-" + method.ID}) {
|
||||
Debit
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id={ "edit-last-four-group-" + method.ID }>
|
||||
@label.Label(label.Props{For: "edit-method-last-four-" + method.ID}) {
|
||||
Last 4 Digits
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "last_four",
|
||||
ID: "edit-method-last-four-" + method.ID,
|
||||
Value: lastFourVal,
|
||||
Attributes: templ.Attributes{
|
||||
"required": "true",
|
||||
"maxlength": "4",
|
||||
"minlength": "4",
|
||||
"pattern": "[0-9]{4}",
|
||||
},
|
||||
})
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
@button.Submit() {
|
||||
Save
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
templ MethodSelector(methods []*model.PaymentMethod, selectedMethodID *string) {
|
||||
<div>
|
||||
@label.Label(label.Props{}) {
|
||||
Payment Method
|
||||
}
|
||||
@selectbox.SelectBox() {
|
||||
@selectbox.Trigger(selectbox.TriggerProps{Name: "payment_method_id"}) {
|
||||
@selectbox.Value(selectbox.ValueProps{Placeholder: "Cash"})
|
||||
}
|
||||
@selectbox.Content(selectbox.ContentProps{NoSearch: len(methods) <= 5}) {
|
||||
@selectbox.Item(selectbox.ItemProps{Value: "", Selected: selectedMethodID == nil}) {
|
||||
Cash
|
||||
}
|
||||
for _, m := range methods {
|
||||
if m.LastFour != nil {
|
||||
@selectbox.Item(selectbox.ItemProps{Value: m.ID, Selected: selectedMethodID != nil && *selectedMethodID == m.ID}) {
|
||||
{ m.Name } (*{ *m.LastFour })
|
||||
}
|
||||
} else {
|
||||
@selectbox.Item(selectbox.ItemProps{Value: m.ID, Selected: selectedMethodID != nil && *selectedMethodID == m.ID}) {
|
||||
{ m.Name }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
|
@ -1,441 +0,0 @@
|
|||
package recurring
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"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/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/paymentmethod"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/radio"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/selectbox"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/tagcombobox"
|
||||
)
|
||||
|
||||
func frequencyLabel(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)
|
||||
}
|
||||
}
|
||||
|
||||
templ RecurringItem(spaceID string, re *model.RecurringExpenseWithTagsAndMethod, methods []*model.PaymentMethod, tags []*model.Tag) {
|
||||
{{ editDialogID := "edit-recurring-" + re.ID }}
|
||||
{{ delDialogID := "del-recurring-" + re.ID }}
|
||||
<div id={ "recurring-" + re.ID } class="p-4 flex justify-between items-start gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="font-medium">{ re.Description }</p>
|
||||
if !re.IsActive {
|
||||
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
|
||||
Paused
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
@badge.Badge(badge.Props{Variant: badge.VariantOutline}) {
|
||||
{ frequencyLabel(re.Frequency) }
|
||||
}
|
||||
<span>Next: { re.NextOccurrence.Format("Jan 02, 2006") }</span>
|
||||
if re.PaymentMethod != nil {
|
||||
if re.PaymentMethod.LastFour != nil {
|
||||
<span>· { re.PaymentMethod.Name } (*{ *re.PaymentMethod.LastFour })</span>
|
||||
} else {
|
||||
<span>· { re.PaymentMethod.Name }</span>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
if len(re.Tags) > 0 {
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
for _, t := range re.Tags {
|
||||
@badge.Badge(badge.Props{Variant: badge.VariantSecondary}) {
|
||||
{ t.Name }
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
if re.Type == model.ExpenseTypeExpense {
|
||||
<p class="font-bold text-destructive">
|
||||
- { model.FormatMoney(re.Amount) }
|
||||
</p>
|
||||
} else {
|
||||
<p class="font-bold text-green-500">
|
||||
+ { model.FormatMoney(re.Amount) }
|
||||
</p>
|
||||
}
|
||||
// Toggle pause/resume
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantGhost,
|
||||
Size: button.SizeIcon,
|
||||
Class: "size-7",
|
||||
Attributes: templ.Attributes{
|
||||
"hx-post": fmt.Sprintf("/app/spaces/%s/recurring/%s/toggle", spaceID, re.ID),
|
||||
"hx-target": "#recurring-" + re.ID,
|
||||
"hx-swap": "outerHTML",
|
||||
},
|
||||
}) {
|
||||
if re.IsActive {
|
||||
@icon.Pause(icon.Props{Size: 14})
|
||||
} else {
|
||||
@icon.Play(icon.Props{Size: 14})
|
||||
}
|
||||
}
|
||||
// Edit button
|
||||
@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 Recurring Transaction
|
||||
}
|
||||
@dialog.Description() {
|
||||
Update the details of this recurring transaction.
|
||||
}
|
||||
}
|
||||
@EditRecurringForm(spaceID, re, methods, tags)
|
||||
}
|
||||
}
|
||||
// Delete button
|
||||
@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 Recurring Transaction
|
||||
}
|
||||
@dialog.Description() {
|
||||
Are you sure you want to delete "{ re.Description }"? This will not remove previously generated expenses.
|
||||
}
|
||||
}
|
||||
@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/recurring/%s", spaceID, re.ID),
|
||||
"hx-target": "#recurring-" + re.ID,
|
||||
"hx-swap": "outerHTML",
|
||||
},
|
||||
}) {
|
||||
Delete
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ AddRecurringForm(spaceID string, tags []*model.Tag, methods []*model.PaymentMethod, dialogID string) {
|
||||
<form
|
||||
hx-post={ "/app/spaces/" + spaceID + "/recurring" }
|
||||
hx-target="#recurring-list"
|
||||
hx-swap="beforeend"
|
||||
_={ "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + dialogID + "') then reset() me end" }
|
||||
class="space-y-4"
|
||||
>
|
||||
@csrf.Token()
|
||||
// Type
|
||||
<div class="flex gap-4">
|
||||
<div class="flex items-start gap-3">
|
||||
@radio.Radio(radio.Props{
|
||||
ID: "recurring-type-expense",
|
||||
Name: "type",
|
||||
Value: "expense",
|
||||
Checked: true,
|
||||
})
|
||||
<div class="grid gap-2">
|
||||
@label.Label(label.Props{For: "recurring-type-expense"}) {
|
||||
Expense
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
@radio.Radio(radio.Props{
|
||||
ID: "recurring-type-topup",
|
||||
Name: "type",
|
||||
Value: "topup",
|
||||
})
|
||||
<div class="grid gap-2">
|
||||
@label.Label(label.Props{For: "recurring-type-topup"}) {
|
||||
Top-up
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
// Description
|
||||
<div>
|
||||
@label.Label(label.Props{For: "recurring-description"}) {
|
||||
Description
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "description",
|
||||
ID: "recurring-description",
|
||||
Attributes: templ.Attributes{"required": "true"},
|
||||
})
|
||||
</div>
|
||||
// Amount
|
||||
<div>
|
||||
@label.Label(label.Props{For: "recurring-amount"}) {
|
||||
Amount
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "amount",
|
||||
ID: "recurring-amount",
|
||||
Type: "number",
|
||||
Attributes: templ.Attributes{"step": "0.01", "required": "true"},
|
||||
})
|
||||
</div>
|
||||
// Frequency
|
||||
<div>
|
||||
@label.Label(label.Props{}) {
|
||||
Frequency
|
||||
}
|
||||
@selectbox.SelectBox(selectbox.Props{ID: "recurring-frequency"}) {
|
||||
@selectbox.Trigger(selectbox.TriggerProps{Name: "frequency"}) {
|
||||
@selectbox.Value()
|
||||
}
|
||||
@selectbox.Content(selectbox.ContentProps{NoSearch: true}) {
|
||||
@selectbox.Item(selectbox.ItemProps{Value: "daily"}) {
|
||||
Daily
|
||||
}
|
||||
@selectbox.Item(selectbox.ItemProps{Value: "weekly"}) {
|
||||
Weekly
|
||||
}
|
||||
@selectbox.Item(selectbox.ItemProps{Value: "biweekly"}) {
|
||||
Biweekly
|
||||
}
|
||||
@selectbox.Item(selectbox.ItemProps{Value: "monthly", Selected: true}) {
|
||||
Monthly
|
||||
}
|
||||
@selectbox.Item(selectbox.ItemProps{Value: "yearly"}) {
|
||||
Yearly
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
// Start Date
|
||||
<div>
|
||||
@label.Label(label.Props{For: "recurring-start-date"}) {
|
||||
Start Date
|
||||
}
|
||||
@datepicker.DatePicker(datepicker.Props{
|
||||
ID: "recurring-start-date",
|
||||
Name: "start_date",
|
||||
Required: true,
|
||||
Clearable: true,
|
||||
})
|
||||
</div>
|
||||
// End Date (optional)
|
||||
<div>
|
||||
@label.Label(label.Props{For: "recurring-end-date"}) {
|
||||
End Date (optional)
|
||||
}
|
||||
@datepicker.DatePicker(datepicker.Props{
|
||||
ID: "recurring-end-date",
|
||||
Name: "end_date",
|
||||
Clearable: true,
|
||||
})
|
||||
</div>
|
||||
// Tags
|
||||
<div>
|
||||
@label.Label(label.Props{For: "recurring-tags"}) {
|
||||
Tags
|
||||
}
|
||||
@tagcombobox.TagCombobox(tagcombobox.Props{
|
||||
ID: "recurring-tags",
|
||||
Name: "tags",
|
||||
Tags: tags,
|
||||
Placeholder: "Search or create tags...",
|
||||
})
|
||||
</div>
|
||||
// Payment Method
|
||||
@paymentmethod.MethodSelector(methods, nil)
|
||||
<div class="flex justify-end">
|
||||
@button.Submit() {
|
||||
Save
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
||||
templ EditRecurringForm(spaceID string, re *model.RecurringExpenseWithTagsAndMethod, methods []*model.PaymentMethod, tags []*model.Tag) {
|
||||
{{ editDialogID := "edit-recurring-" + re.ID }}
|
||||
{{ tagValues := make([]string, len(re.Tags)) }}
|
||||
for i, t := range re.Tags {
|
||||
{{ tagValues[i] = t.Name }}
|
||||
}
|
||||
<form
|
||||
hx-patch={ fmt.Sprintf("/app/spaces/%s/recurring/%s", spaceID, re.ID) }
|
||||
hx-target={ "#recurring-" + re.ID }
|
||||
hx-swap="outerHTML"
|
||||
_={ "on htmx:afterOnLoad if event.detail.xhr.status == 200 then call window.tui.dialog.close('" + editDialogID + "') end" }
|
||||
class="space-y-4"
|
||||
>
|
||||
@csrf.Token()
|
||||
// Type
|
||||
<div class="flex gap-4">
|
||||
<div class="flex items-start gap-3">
|
||||
@radio.Radio(radio.Props{
|
||||
ID: "edit-recurring-type-expense-" + re.ID,
|
||||
Name: "type",
|
||||
Value: "expense",
|
||||
Checked: re.Type == model.ExpenseTypeExpense,
|
||||
})
|
||||
<div class="grid gap-2">
|
||||
@label.Label(label.Props{For: "edit-recurring-type-expense-" + re.ID}) {
|
||||
Expense
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-start gap-3">
|
||||
@radio.Radio(radio.Props{
|
||||
ID: "edit-recurring-type-topup-" + re.ID,
|
||||
Name: "type",
|
||||
Value: "topup",
|
||||
Checked: re.Type == model.ExpenseTypeTopup,
|
||||
})
|
||||
<div class="grid gap-2">
|
||||
@label.Label(label.Props{For: "edit-recurring-type-topup-" + re.ID}) {
|
||||
Top-up
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
// Description
|
||||
<div>
|
||||
@label.Label(label.Props{For: "edit-recurring-desc-" + re.ID}) {
|
||||
Description
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "description",
|
||||
ID: "edit-recurring-desc-" + re.ID,
|
||||
Value: re.Description,
|
||||
Attributes: templ.Attributes{"required": "true"},
|
||||
})
|
||||
</div>
|
||||
// Amount
|
||||
<div>
|
||||
@label.Label(label.Props{For: "edit-recurring-amount-" + re.ID}) {
|
||||
Amount
|
||||
}
|
||||
@input.Input(input.Props{
|
||||
Name: "amount",
|
||||
ID: "edit-recurring-amount-" + re.ID,
|
||||
Type: "number",
|
||||
Value: model.FormatDecimal(re.Amount),
|
||||
Attributes: templ.Attributes{"step": "0.01", "required": "true"},
|
||||
})
|
||||
</div>
|
||||
// Frequency
|
||||
<div>
|
||||
@label.Label(label.Props{}) {
|
||||
Frequency
|
||||
}
|
||||
@selectbox.SelectBox(selectbox.Props{ID: "edit-recurring-freq-" + re.ID}) {
|
||||
@selectbox.Trigger(selectbox.TriggerProps{Name: "frequency"}) {
|
||||
@selectbox.Value()
|
||||
}
|
||||
@selectbox.Content(selectbox.ContentProps{NoSearch: true}) {
|
||||
@selectbox.Item(selectbox.ItemProps{Value: "daily", Selected: re.Frequency == model.FrequencyDaily}) {
|
||||
Daily
|
||||
}
|
||||
@selectbox.Item(selectbox.ItemProps{Value: "weekly", Selected: re.Frequency == model.FrequencyWeekly}) {
|
||||
Weekly
|
||||
}
|
||||
@selectbox.Item(selectbox.ItemProps{Value: "biweekly", Selected: re.Frequency == model.FrequencyBiweekly}) {
|
||||
Biweekly
|
||||
}
|
||||
@selectbox.Item(selectbox.ItemProps{Value: "monthly", Selected: re.Frequency == model.FrequencyMonthly}) {
|
||||
Monthly
|
||||
}
|
||||
@selectbox.Item(selectbox.ItemProps{Value: "yearly", Selected: re.Frequency == model.FrequencyYearly}) {
|
||||
Yearly
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
// Start Date
|
||||
<div>
|
||||
@label.Label(label.Props{For: "edit-recurring-start-" + re.ID}) {
|
||||
Start Date
|
||||
}
|
||||
@datepicker.DatePicker(datepicker.Props{
|
||||
ID: "edit-recurring-start-" + re.ID,
|
||||
Name: "start_date",
|
||||
Value: re.StartDate,
|
||||
Required: true,
|
||||
Clearable: true,
|
||||
})
|
||||
</div>
|
||||
// End Date (optional)
|
||||
<div>
|
||||
@label.Label(label.Props{For: "edit-recurring-end-" + re.ID}) {
|
||||
End Date (optional)
|
||||
}
|
||||
if re.EndDate != nil {
|
||||
@datepicker.DatePicker(datepicker.Props{
|
||||
ID: "edit-recurring-end-" + re.ID,
|
||||
Name: "end_date",
|
||||
Value: *re.EndDate,
|
||||
Clearable: true,
|
||||
})
|
||||
} else {
|
||||
@datepicker.DatePicker(datepicker.Props{
|
||||
ID: "edit-recurring-end-" + re.ID,
|
||||
Name: "end_date",
|
||||
Clearable: true,
|
||||
})
|
||||
}
|
||||
</div>
|
||||
// Tags
|
||||
<div>
|
||||
@label.Label(label.Props{For: "edit-recurring-tags-" + re.ID}) {
|
||||
Tags
|
||||
}
|
||||
@tagcombobox.TagCombobox(tagcombobox.Props{
|
||||
ID: "edit-recurring-tags-" + re.ID,
|
||||
Name: "tags",
|
||||
Value: tagValues,
|
||||
Tags: tags,
|
||||
Placeholder: "Search or create tags...",
|
||||
})
|
||||
</div>
|
||||
// Payment Method
|
||||
@paymentmethod.MethodSelector(methods, re.PaymentMethodID)
|
||||
<div class="flex justify-end">
|
||||
@button.Submit() {
|
||||
Save
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
}
|
||||
|
|
@ -1,300 +0,0 @@
|
|||
package shoppinglist
|
||||
|
||||
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/checkbox"
|
||||
"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/pagination"
|
||||
)
|
||||
|
||||
// ListCard renders a full shopping list card with inline items, add form, and pagination.
|
||||
templ ListCard(spaceID string, list *model.ShoppingList, items []*model.ListItem, currentPage, totalPages int) {
|
||||
<div id={ "list-card-" + list.ID } class="border rounded-lg overflow-hidden flex flex-col">
|
||||
<div class="p-4 border-b bg-muted/30">
|
||||
@ListCardHeader(spaceID, list)
|
||||
</div>
|
||||
<div class="p-3 border-b">
|
||||
<form
|
||||
hx-post={ fmt.Sprintf("/app/spaces/%s/lists/%s/items", spaceID, list.ID) }
|
||||
hx-swap="none"
|
||||
_={ fmt.Sprintf("on htmx:afterRequest if event.detail.successful reset() me then send refreshItems to #list-items-%s", list.ID) }
|
||||
class="flex gap-2 items-start"
|
||||
>
|
||||
@csrf.Token()
|
||||
@input.Input(input.Props{
|
||||
Name: "name",
|
||||
Placeholder: "Add item...",
|
||||
Class: "h-8 text-sm",
|
||||
Attributes: templ.Attributes{
|
||||
"autocomplete": "off",
|
||||
},
|
||||
})
|
||||
@button.Submit(button.Props{
|
||||
Size: button.SizeSm,
|
||||
}) {
|
||||
@icon.Plus(icon.Props{Size: 16})
|
||||
}
|
||||
</form>
|
||||
</div>
|
||||
<div
|
||||
id={ "list-items-" + list.ID }
|
||||
hx-get={ fmt.Sprintf("/app/spaces/%s/lists/%s/card-items?page=1", spaceID, list.ID) }
|
||||
hx-trigger="refreshItems"
|
||||
hx-swap="innerHTML"
|
||||
>
|
||||
@ListCardItems(spaceID, list.ID, items, currentPage, totalPages)
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
// ListCardHeader renders the card header with name display, edit form, and delete button.
|
||||
templ ListCardHeader(spaceID string, list *model.ShoppingList) {
|
||||
<div id={ "lch-" + list.ID } class="flex items-center justify-between gap-2">
|
||||
<h3 class="font-semibold truncate">{ list.Name }</h3>
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantGhost,
|
||||
Size: button.SizeIcon,
|
||||
Class: "size-7 text-muted-foreground hover:text-foreground",
|
||||
Attributes: templ.Attributes{
|
||||
"_": fmt.Sprintf("on click toggle .hidden on #lch-%s then toggle .hidden on #lche-%s then focus() the first <input/> in #lche-%s", list.ID, list.ID, list.ID),
|
||||
},
|
||||
}) {
|
||||
@icon.Pencil(icon.Props{Size: 14})
|
||||
}
|
||||
@dialog.Dialog(dialog.Props{ID: "del-list-" + list.ID}) {
|
||||
@dialog.Trigger() {
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantGhost,
|
||||
Size: button.SizeIcon,
|
||||
Class: "size-7 text-muted-foreground hover:text-destructive",
|
||||
}) {
|
||||
@icon.Trash2(icon.Props{Size: 14})
|
||||
}
|
||||
}
|
||||
@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.
|
||||
}
|
||||
}
|
||||
@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/lists/%s?from=card", spaceID, list.ID),
|
||||
"hx-target": "#list-card-" + list.ID,
|
||||
"hx-swap": "outerHTML",
|
||||
},
|
||||
}) {
|
||||
Delete
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
id={ "lche-" + list.ID }
|
||||
class="hidden flex items-center gap-2"
|
||||
hx-patch={ fmt.Sprintf("/app/spaces/%s/lists/%s?from=card", spaceID, list.ID) }
|
||||
hx-target={ "#lch-" + list.ID }
|
||||
hx-swap="outerHTML"
|
||||
_={ fmt.Sprintf("on htmx:afterRequest toggle .hidden on me then toggle .hidden on #lch-%s", list.ID) }
|
||||
>
|
||||
@csrf.Token()
|
||||
@input.Input(input.Props{
|
||||
Name: "name",
|
||||
Value: list.Name,
|
||||
Class: "h-8 text-sm",
|
||||
Attributes: templ.Attributes{
|
||||
"required": "true",
|
||||
},
|
||||
})
|
||||
@button.Submit(button.Props{Size: button.SizeSm}) {
|
||||
Save
|
||||
}
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantOutline,
|
||||
Size: button.SizeSm,
|
||||
Attributes: templ.Attributes{
|
||||
"_": fmt.Sprintf("on click toggle .hidden on #lche-%s then toggle .hidden on #lch-%s", list.ID, list.ID),
|
||||
},
|
||||
}) {
|
||||
Cancel
|
||||
}
|
||||
</form>
|
||||
}
|
||||
|
||||
// ListCardItems renders the paginated items section within a card.
|
||||
templ ListCardItems(spaceID string, listID string, items []*model.ListItem, currentPage, totalPages int) {
|
||||
if len(items) == 0 {
|
||||
<p class="text-center text-muted-foreground p-6 text-sm">No items yet</p>
|
||||
} else {
|
||||
<div class="divide-y">
|
||||
for _, item := range items {
|
||||
@CardItemDetail(spaceID, item)
|
||||
}
|
||||
</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/lists/%s/card-items?page=%d", spaceID, listID, currentPage-1),
|
||||
"hx-target": "#list-items-" + listID,
|
||||
"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/lists/%s/card-items?page=%d", spaceID, listID, pg),
|
||||
"hx-target": "#list-items-" + listID,
|
||||
"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/lists/%s/card-items?page=%d", spaceID, listID, currentPage+1),
|
||||
"hx-target": "#list-items-" + listID,
|
||||
"hx-swap": "innerHTML",
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// CardItemDetail renders an item within a card. Toggle is in-place, delete triggers a refresh.
|
||||
templ CardItemDetail(spaceID string, item *model.ListItem) {
|
||||
<div id={ "item-" + item.ID } class="flex items-center gap-2 px-4 py-2">
|
||||
@checkbox.Checkbox(checkbox.Props{
|
||||
ID: "item-" + item.ID + "-checkbox",
|
||||
Name: "is_checked",
|
||||
Checked: item.IsChecked,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-patch": fmt.Sprintf("/app/spaces/%s/lists/%s/items/%s?from=card", spaceID, item.ListID, item.ID),
|
||||
"hx-target": "#item-" + item.ID,
|
||||
"hx-swap": "outerHTML",
|
||||
},
|
||||
})
|
||||
<span class={ "text-sm flex-1", templ.KV("line-through text-muted-foreground", item.IsChecked) }>{ item.Name }</span>
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantGhost,
|
||||
Size: button.SizeIcon,
|
||||
Class: "size-7 text-muted-foreground hover:text-destructive shrink-0",
|
||||
Attributes: templ.Attributes{
|
||||
"hx-delete": fmt.Sprintf("/app/spaces/%s/lists/%s/items/%s", spaceID, item.ListID, item.ID),
|
||||
"hx-swap": "none",
|
||||
"_": fmt.Sprintf("on htmx:afterRequest send refreshItems to #list-items-%s", item.ListID),
|
||||
},
|
||||
}) {
|
||||
@icon.X(icon.Props{Size: 14})
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
// ListNameHeader is used on the detail page for editing list name inline.
|
||||
templ ListNameHeader(spaceID string, list *model.ShoppingList) {
|
||||
<div id="list-name-header" class="flex items-center gap-2 group">
|
||||
<h1 class="text-2xl font-bold">{ list.Name }</h1>
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantGhost,
|
||||
Size: button.SizeIcon,
|
||||
Class: "size-7 text-muted-foreground hover:text-foreground opacity-0 group-hover:opacity-100 transition-opacity",
|
||||
Attributes: templ.Attributes{
|
||||
"_": "on click toggle .hidden on #list-name-header then toggle .hidden on #list-name-edit then focus() the first <input/> in #list-name-edit",
|
||||
},
|
||||
}) {
|
||||
@icon.Pencil(icon.Props{Size: 16})
|
||||
}
|
||||
</div>
|
||||
<form
|
||||
id="list-name-edit"
|
||||
class="hidden flex items-center gap-2"
|
||||
hx-patch={ fmt.Sprintf("/app/spaces/%s/lists/%s", spaceID, list.ID) }
|
||||
hx-target="#list-name-header"
|
||||
hx-swap="outerHTML"
|
||||
_="on htmx:afterRequest toggle .hidden on me then toggle .hidden on #list-name-header"
|
||||
>
|
||||
@csrf.Token()
|
||||
@input.Input(input.Props{
|
||||
Name: "name",
|
||||
Value: list.Name,
|
||||
Class: "max-w-xs",
|
||||
Attributes: templ.Attributes{
|
||||
"required": "true",
|
||||
},
|
||||
})
|
||||
@button.Submit() {
|
||||
Save
|
||||
}
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantOutline,
|
||||
Attributes: templ.Attributes{
|
||||
"_": "on click toggle .hidden on #list-name-edit then toggle .hidden on #list-name-header",
|
||||
},
|
||||
}) {
|
||||
Cancel
|
||||
}
|
||||
</form>
|
||||
}
|
||||
|
||||
// ItemDetail renders an individual item row (used by the detail page and toggle responses).
|
||||
templ ItemDetail(spaceID string, item *model.ListItem) {
|
||||
<div id={ "item-" + item.ID } class="flex items-center gap-2 p-2 border-b">
|
||||
@checkbox.Checkbox(checkbox.Props{
|
||||
ID: "item-" + item.ID + "-checkbox",
|
||||
Name: "is_checked",
|
||||
Checked: item.IsChecked,
|
||||
Attributes: templ.Attributes{
|
||||
"hx-patch": fmt.Sprintf("/app/spaces/%s/lists/%s/items/%s", spaceID, item.ListID, item.ID),
|
||||
"hx-target": "#item-" + item.ID,
|
||||
"hx-swap": "outerHTML",
|
||||
},
|
||||
})
|
||||
<span class={ templ.KV("line-through text-muted-foreground", item.IsChecked) }>{ item.Name }</span>
|
||||
@button.Button(button.Props{
|
||||
Variant: button.VariantGhost,
|
||||
Size: button.SizeIcon,
|
||||
Class: "ml-auto size-7",
|
||||
Attributes: templ.Attributes{
|
||||
"hx-delete": fmt.Sprintf("/app/spaces/%s/lists/%s/items/%s", spaceID, item.ListID, item.ID),
|
||||
"hx-target": "#item-" + item.ID,
|
||||
"hx-swap": "outerHTML",
|
||||
},
|
||||
}) {
|
||||
@icon.X(icon.Props{Size: 14})
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
package tag
|
||||
|
||||
import "git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
|
||||
templ Tag(tag *model.Tag) {
|
||||
<div
|
||||
id={ "tag-" + tag.ID }
|
||||
class="flex items-center gap-2 rounded-full border px-3 py-1 text-sm"
|
||||
>
|
||||
if tag.Color != nil {
|
||||
<span class="size-3 rounded-full" style={ "background-color: " + *tag.Color }></span>
|
||||
}
|
||||
<span>{ tag.Name }</span>
|
||||
<button
|
||||
hx-delete={ "/app/spaces/" + tag.SpaceID + "/tags/" + tag.ID }
|
||||
hx-target={ "#tag-" + tag.ID }
|
||||
hx-swap="outerHTML"
|
||||
class="ml-auto text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
|
@ -1,158 +0,0 @@
|
|||
package tagcombobox
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/utils"
|
||||
)
|
||||
|
||||
type Props struct {
|
||||
ID string
|
||||
Name string // form field name (default "tags")
|
||||
Value []string // pre-selected tag names
|
||||
Tags []*model.Tag // all available tags in the space
|
||||
Placeholder string
|
||||
HasError bool
|
||||
Disabled bool
|
||||
Form string // associate with external form
|
||||
}
|
||||
|
||||
func (p Props) fieldName() string {
|
||||
if p.Name != "" {
|
||||
return p.Name
|
||||
}
|
||||
return "tags"
|
||||
}
|
||||
|
||||
func (p Props) isSelected(tagName string) bool {
|
||||
lower := strings.ToLower(tagName)
|
||||
for _, v := range p.Value {
|
||||
if strings.ToLower(v) == lower {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
templ TagCombobox(props Props) {
|
||||
<div
|
||||
id={ props.ID + "-container" }
|
||||
class={
|
||||
utils.TwMerge(
|
||||
"relative w-full",
|
||||
),
|
||||
}
|
||||
data-tagcombobox
|
||||
data-tagcombobox-name={ props.fieldName() }
|
||||
data-tagcombobox-form={ props.Form }
|
||||
>
|
||||
// Main input area styled like tagsinput
|
||||
<div
|
||||
class={
|
||||
utils.TwMerge(
|
||||
"flex items-center flex-wrap gap-2 p-2 rounded-md border border-input bg-transparent shadow-xs transition-[color,box-shadow] outline-none cursor-text",
|
||||
"dark:bg-input/30",
|
||||
"focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]",
|
||||
utils.If(props.Disabled, "opacity-50 cursor-not-allowed"),
|
||||
"w-full min-h-[38px]",
|
||||
utils.If(props.HasError, "border-destructive ring-destructive/20 dark:ring-destructive/40"),
|
||||
),
|
||||
}
|
||||
data-tagcombobox-input-area
|
||||
>
|
||||
// Selected tag chips
|
||||
<div class="flex items-center flex-wrap gap-2" data-tagcombobox-chips>
|
||||
for _, val := range props.Value {
|
||||
@badge.Badge(badge.Props{
|
||||
Attributes: templ.Attributes{"data-tagcombobox-chip": val},
|
||||
}) {
|
||||
<span>{ val }</span>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-1 text-current hover:text-destructive disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
|
||||
disabled?={ props.Disabled }
|
||||
data-tagcombobox-remove={ val }
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
// Text input for searching/typing
|
||||
<input
|
||||
type="text"
|
||||
id={ props.ID }
|
||||
class="border-0 shadow-none focus-visible:ring-0 focus-visible:outline-none h-auto py-0 px-0 bg-transparent rounded-none min-h-0 disabled:opacity-100 dark:bg-transparent flex-1 min-w-[80px] text-sm"
|
||||
placeholder={ props.Placeholder }
|
||||
disabled?={ props.Disabled }
|
||||
autocomplete="off"
|
||||
data-tagcombobox-text-input
|
||||
/>
|
||||
</div>
|
||||
// Dropdown
|
||||
<div
|
||||
class="absolute z-50 mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-md hidden max-h-60 overflow-y-auto"
|
||||
data-tagcombobox-dropdown
|
||||
>
|
||||
for _, tag := range props.Tags {
|
||||
<div
|
||||
class={
|
||||
"flex items-center gap-2 px-3 py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground",
|
||||
templ.KV("font-medium", props.isSelected(tag.Name)),
|
||||
}
|
||||
data-tagcombobox-item={ tag.Name }
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class={
|
||||
"h-4 w-4 shrink-0",
|
||||
templ.KV("opacity-100", props.isSelected(tag.Name)),
|
||||
templ.KV("opacity-0", !props.isSelected(tag.Name)),
|
||||
}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
data-tagcombobox-check
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
if tag.Color != nil {
|
||||
<span class="inline-block w-3 h-3 rounded-full shrink-0" style={ "background-color: " + *tag.Color }></span>
|
||||
}
|
||||
<span data-tagcombobox-item-label>{ tag.Name }</span>
|
||||
</div>
|
||||
}
|
||||
// "Create new" option (hidden by default, shown when typing something new)
|
||||
<div
|
||||
class="hidden items-center gap-2 px-3 py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground text-muted-foreground"
|
||||
data-tagcombobox-create
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"></path>
|
||||
</svg>
|
||||
<span>Create "<span data-tagcombobox-create-label></span>"</span>
|
||||
</div>
|
||||
</div>
|
||||
// Hidden inputs for form submission
|
||||
<div data-tagcombobox-hidden-inputs>
|
||||
for _, val := range props.Value {
|
||||
<input
|
||||
type="hidden"
|
||||
name={ props.fieldName() }
|
||||
value={ val }
|
||||
if props.Form != "" {
|
||||
form={ props.Form }
|
||||
}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
templ Script() {
|
||||
<script defer nonce={ templ.GetNonce(ctx) } src={ utils.ScriptURL("/assets/js/tagcombobox.js") }></script>
|
||||
}
|
||||
|
|
@ -75,9 +75,8 @@ templ App(title string) {
|
|||
@sidebar.Menu() {
|
||||
@sidebar.MenuItem() {
|
||||
{{ user := ctxkeys.User(ctx) }}
|
||||
{{ profile := ctxkeys.Profile(ctx) }}
|
||||
if user != nil && profile != nil {
|
||||
@AppSidebarDropdown(user, profile)
|
||||
if user != nil {
|
||||
@AppSidebarDropdown(user)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -113,7 +112,9 @@ templ App(title string) {
|
|||
}
|
||||
}
|
||||
|
||||
templ AppSidebarDropdown(user *model.User, profile *model.Profile) {
|
||||
templ AppSidebarDropdown(user *model.User) {
|
||||
{{ displayName := user.Email }}
|
||||
{{ if user.Name != nil && *user.Name != "" { displayName = *user.Name } }}
|
||||
@dropdown.Dropdown() {
|
||||
@dropdown.Trigger() {
|
||||
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||
|
|
@ -121,16 +122,13 @@ templ AppSidebarDropdown(user *model.User, profile *model.Profile) {
|
|||
}) {
|
||||
<div id="sidebar-avatar" hx-swap-oob="true">
|
||||
@avatar.Avatar(avatar.Props{Class: "size-8 rounded-lg"}) {
|
||||
<!-- if user.AvatarURL != "" { -->
|
||||
<!-- @avatar.Image(avatar.ImageProps{Src: user.AvatarURL, Alt: profile.Name}) -->
|
||||
<!-- } -->
|
||||
@avatar.Fallback() {
|
||||
{ strings.ToUpper(string(profile.Name[0])) }
|
||||
{ strings.ToUpper(string(displayName[0])) }
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div id="sidebar-user-name" hx-swap-oob="true" class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-medium">{ profile.Name }</span>
|
||||
<span class="truncate font-medium">{ displayName }</span>
|
||||
<span class="truncate text-xs text-muted-foreground">{ user.Email }</span>
|
||||
</div>
|
||||
@icon.ChevronsUpDown(icon.Props{Class: "ml-auto size-4"})
|
||||
|
|
@ -143,7 +141,7 @@ templ AppSidebarDropdown(user *model.User, profile *model.Profile) {
|
|||
<div id="dropdown-user-label" hx-swap-oob="true">
|
||||
@dropdown.Label() {
|
||||
<div class="flex flex-col">
|
||||
<span class="font-medium">{ profile.Name }</span>
|
||||
<span class="font-medium">{ displayName }</span>
|
||||
<span class="text-xs text-muted-foreground">{ user.Email }</span>
|
||||
</div>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import "git.juancwu.dev/juancwu/budgit/internal/ui/components/datepicker"
|
|||
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/progress"
|
||||
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/selectbox"
|
||||
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/tagsinput"
|
||||
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/tagcombobox"
|
||||
|
||||
import "fmt"
|
||||
import "time"
|
||||
|
||||
|
|
@ -54,7 +54,7 @@ templ Base(props ...SEOProps) {
|
|||
@progress.Script()
|
||||
@tagsinput.Script()
|
||||
@selectbox.Script()
|
||||
@tagcombobox.Script()
|
||||
|
||||
// Site-wide enhancements
|
||||
@themeScript()
|
||||
// Must run before body to prevent flash
|
||||
|
|
|
|||
|
|
@ -1,226 +0,0 @@
|
|||
package layouts
|
||||
|
||||
import (
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/model"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/blocks"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/breadcrumb"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/icon"
|
||||
"git.juancwu.dev/juancwu/budgit/internal/ui/components/sidebar"
|
||||
"strings"
|
||||
)
|
||||
|
||||
templ Space(title string, space *model.Space) {
|
||||
{{ cfg := ctxkeys.Config(ctx) }}
|
||||
@Base(SEOProps{
|
||||
Title: title,
|
||||
Description: "Space Dashboard",
|
||||
Path: ctxkeys.URLPath(ctx),
|
||||
}) {
|
||||
@sidebar.Layout() {
|
||||
@sidebar.Sidebar() {
|
||||
@sidebar.Header() {
|
||||
@sidebar.Menu() {
|
||||
@sidebar.MenuItem() {
|
||||
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||
Size: sidebar.MenuButtonSizeLg,
|
||||
Href: "/app/spaces",
|
||||
}) {
|
||||
@icon.LayoutDashboard()
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-bold">{ cfg.AppName }</span>
|
||||
<span class="text-xs text-muted-foreground">Back to Spaces</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@sidebar.Content() {
|
||||
@sidebar.Group() {
|
||||
@sidebar.GroupLabel() {
|
||||
{ space.Name }
|
||||
}
|
||||
@sidebar.Menu() {
|
||||
@sidebar.MenuItem() {
|
||||
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||
Href: "/app/spaces/" + space.ID,
|
||||
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID,
|
||||
Tooltip: "Overview",
|
||||
}) {
|
||||
@icon.House(icon.Props{Class: "size-4"})
|
||||
<span>Overview</span>
|
||||
}
|
||||
}
|
||||
@sidebar.MenuItem() {
|
||||
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||
Href: "/app/spaces/" + space.ID + "/reports",
|
||||
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/reports",
|
||||
Tooltip: "Reports",
|
||||
}) {
|
||||
@icon.ChartPie(icon.Props{Class: "size-4"})
|
||||
<span>Reports</span>
|
||||
}
|
||||
}
|
||||
@sidebar.MenuItem() {
|
||||
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||
Href: "/app/spaces/" + space.ID + "/expenses",
|
||||
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/expenses",
|
||||
Tooltip: "Expenses",
|
||||
}) {
|
||||
@icon.DollarSign(icon.Props{Class: "size-4"})
|
||||
<span>Expenses</span>
|
||||
}
|
||||
}
|
||||
@sidebar.MenuItem() {
|
||||
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||
Href: "/app/spaces/" + space.ID + "/recurring",
|
||||
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/recurring",
|
||||
Tooltip: "Recurring Transactions",
|
||||
}) {
|
||||
@icon.Repeat(icon.Props{Class: "size-4"})
|
||||
<span>Recurring</span>
|
||||
}
|
||||
}
|
||||
@sidebar.MenuItem() {
|
||||
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||
Href: "/app/spaces/" + space.ID + "/budgets",
|
||||
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/budgets",
|
||||
Tooltip: "Budgets",
|
||||
}) {
|
||||
@icon.Target(icon.Props{Class: "size-4"})
|
||||
<span>Budgets</span>
|
||||
}
|
||||
}
|
||||
@sidebar.MenuItem() {
|
||||
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||
Href: "/app/spaces/" + space.ID + "/loans",
|
||||
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/loans" || strings.HasPrefix(ctxkeys.URLPath(ctx), "/app/spaces/"+space.ID+"/loans/"),
|
||||
Tooltip: "Loans",
|
||||
}) {
|
||||
@icon.Landmark(icon.Props{Class: "size-4"})
|
||||
<span>Loans</span>
|
||||
}
|
||||
}
|
||||
@sidebar.MenuItem() {
|
||||
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||
Href: "/app/spaces/" + space.ID + "/accounts",
|
||||
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/accounts",
|
||||
Tooltip: "Money Accounts",
|
||||
}) {
|
||||
@icon.PiggyBank(icon.Props{Class: "size-4"})
|
||||
<span>Accounts</span>
|
||||
}
|
||||
}
|
||||
@sidebar.MenuItem() {
|
||||
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||
Href: "/app/spaces/" + space.ID + "/payment-methods",
|
||||
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/payment-methods",
|
||||
Tooltip: "Payment Methods",
|
||||
}) {
|
||||
@icon.CreditCard(icon.Props{Class: "size-4"})
|
||||
<span>Payment Methods</span>
|
||||
}
|
||||
}
|
||||
@sidebar.MenuItem() {
|
||||
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||
Href: "/app/spaces/" + space.ID + "/lists",
|
||||
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/lists",
|
||||
Tooltip: "Shopping Lists",
|
||||
}) {
|
||||
@icon.ShoppingCart(icon.Props{Class: "size-4"})
|
||||
<span>Shopping Lists</span>
|
||||
}
|
||||
}
|
||||
@sidebar.MenuItem() {
|
||||
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||
Href: "/app/spaces/" + space.ID + "/tags",
|
||||
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/tags",
|
||||
Tooltip: "Tags",
|
||||
}) {
|
||||
@icon.Tag(icon.Props{Class: "size-4"})
|
||||
<span>Tags</span>
|
||||
}
|
||||
}
|
||||
@sidebar.MenuItem() {
|
||||
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||
Href: "/app/spaces/" + space.ID + "/settings",
|
||||
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/settings",
|
||||
Tooltip: "Settings",
|
||||
}) {
|
||||
@icon.Settings(icon.Props{Class: "size-4"})
|
||||
<span>Settings</span>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@sidebar.Footer() {
|
||||
@sidebar.Menu() {
|
||||
@sidebar.MenuItem() {
|
||||
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||
Href: "mailto:" + cfg.SupportEmail,
|
||||
Size: sidebar.MenuButtonSizeSm,
|
||||
}) {
|
||||
@icon.MessageCircleQuestionMark(icon.Props{Class: "size-4"})
|
||||
<span>Support</span>
|
||||
}
|
||||
}
|
||||
}
|
||||
<div class="px-2 py-1">
|
||||
<span class="text-xs text-muted-foreground">App version: { cfg.Version }</span>
|
||||
</div>
|
||||
@sidebar.Separator()
|
||||
@sidebar.Menu() {
|
||||
@sidebar.MenuItem() {
|
||||
{{ user := ctxkeys.User(ctx) }}
|
||||
{{ profile := ctxkeys.Profile(ctx) }}
|
||||
if user != nil && profile != nil {
|
||||
@AppSidebarDropdown(user, profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@sidebar.Inset() {
|
||||
<div class="flex flex-col h-full">
|
||||
// Top Navigation Bar
|
||||
<header class="sticky top-0 z-10 border-b bg-background">
|
||||
<div class="flex h-14 items-center px-6">
|
||||
<div class="flex items-center gap-4">
|
||||
@sidebar.Trigger()
|
||||
@breadcrumb.Breadcrumb() {
|
||||
@breadcrumb.List() {
|
||||
@breadcrumb.Item() {
|
||||
@breadcrumb.Link(breadcrumb.LinkProps{Href: "/app/spaces"}) {
|
||||
Spaces
|
||||
}
|
||||
}
|
||||
@breadcrumb.Separator()
|
||||
@breadcrumb.Item() {
|
||||
@breadcrumb.Link(breadcrumb.LinkProps{Href: "/app/spaces/" + space.ID}) {
|
||||
{ space.Name }
|
||||
}
|
||||
}
|
||||
@breadcrumb.Separator()
|
||||
@breadcrumb.Item() {
|
||||
@breadcrumb.Page() {
|
||||
{ title }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="ml-auto flex items-center gap-4">
|
||||
@blocks.ThemeSwitcher()
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
// App Content
|
||||
<main class="flex-1 p-6">
|
||||
{ children... }
|
||||
</main>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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>· { exp.PaymentMethod.Name } (*{ *exp.PaymentMethod.LastFour })</span>
|
||||
} else {
|
||||
<span>· { exp.PaymentMethod.Name }</span>
|
||||
}
|
||||
} else {
|
||||
<span>· 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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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) } · 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>
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue