chore: massive reset
This commit is contained in:
parent
c7ee3da8f2
commit
df164ab0f4
96 changed files with 198 additions and 15405 deletions
|
|
@ -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>
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue