feat: recurring expenses and reports

This commit is contained in:
juancwu 2026-02-14 17:00:15 +00:00
commit 9e6ff67a87
23 changed files with 2943 additions and 56 deletions

View file

@ -0,0 +1,409 @@
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/tagsinput"
)
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) {
{{ 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>&middot; { re.PaymentMethod.Name } (*{ *re.PaymentMethod.LastFour })</span>
} else {
<span>&middot; { 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">
- { fmt.Sprintf("$%.2f", float64(re.AmountCents)/100.0) }
</p>
} else {
<p class="font-bold text-green-500">
+ { fmt.Sprintf("$%.2f", float64(re.AmountCents)/100.0) }
</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)
}
}
// 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{For: "recurring-frequency"}) {
Frequency
}
<select name="frequency" id="recurring-frequency" class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background" required>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="biweekly">Biweekly</option>
<option value="monthly" selected>Monthly</option>
<option value="yearly">Yearly</option>
</select>
</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",
Attributes: templ.Attributes{"required": "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",
})
</div>
// Tags
<div>
@label.Label(label.Props{For: "recurring-tags"}) {
Tags
}
<datalist id="recurring-available-tags">
for _, t := range tags {
<option value={ t.Name }></option>
}
</datalist>
@tagsinput.TagsInput(tagsinput.Props{
ID: "recurring-tags",
Name: "tags",
Placeholder: "Add tags (press enter)",
Attributes: templ.Attributes{"list": "recurring-available-tags"},
})
</div>
// Payment Method
@paymentmethod.MethodSelector(methods, nil)
<div class="flex justify-end">
@button.Button(button.Props{Type: button.TypeSubmit}) {
Save
}
</div>
</form>
}
templ EditRecurringForm(spaceID string, re *model.RecurringExpenseWithTagsAndMethod, methods []*model.PaymentMethod) {
{{ 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: fmt.Sprintf("%.2f", float64(re.AmountCents)/100.0),
Attributes: templ.Attributes{"step": "0.01", "required": "true"},
})
</div>
// Frequency
<div>
@label.Label(label.Props{For: "edit-recurring-freq-" + re.ID}) {
Frequency
}
<select name="frequency" id={ "edit-recurring-freq-" + re.ID } class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background" required>
<option value="daily" selected?={ re.Frequency == model.FrequencyDaily }>Daily</option>
<option value="weekly" selected?={ re.Frequency == model.FrequencyWeekly }>Weekly</option>
<option value="biweekly" selected?={ re.Frequency == model.FrequencyBiweekly }>Biweekly</option>
<option value="monthly" selected?={ re.Frequency == model.FrequencyMonthly }>Monthly</option>
<option value="yearly" selected?={ re.Frequency == model.FrequencyYearly }>Yearly</option>
</select>
</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,
Attributes: templ.Attributes{"required": "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,
})
} else {
@datepicker.DatePicker(datepicker.Props{
ID: "edit-recurring-end-" + re.ID,
Name: "end_date",
})
}
</div>
// Tags
<div>
@label.Label(label.Props{For: "edit-recurring-tags-" + re.ID}) {
Tags
}
@tagsinput.TagsInput(tagsinput.Props{
ID: "edit-recurring-tags-" + re.ID,
Name: "tags",
Value: tagValues,
Placeholder: "Add tags (press enter)",
})
</div>
// Payment Method
@paymentmethod.MethodSelector(methods, re.PaymentMethodID)
<div class="flex justify-end">
@button.Button(button.Props{Type: button.TypeSubmit}) {
Save
}
</div>
</form>
}

View file

@ -60,6 +60,36 @@ templ Space(title string, space *model.Space) {
<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 + "/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 + "/accounts",

View file

@ -0,0 +1,382 @@
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/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 a tag category.
}
}
@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">
if b.TagColor != nil {
<span class="inline-block w-3 h-3 rounded-full" style={ "background-color: " + *b.TagColor }></span>
}
<h3 class="font-semibold">{ b.TagName }</h3>
</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 the budget for "{ b.TagName }"?
}
}
@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>{ fmt.Sprintf("$%.2f", float64(b.SpentCents)/100.0) } spent</span>
<span>of { fmt.Sprintf("$%.2f", float64(b.AmountCents)/100.0) }</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 { fmt.Sprintf("$%.2f", float64(b.SpentCents-b.AmountCents)/100.0) }</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-tag"}) {
Tag
}
<select name="tag_id" id="budget-tag" class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background" required>
<option value="" disabled selected>Select a tag...</option>
for _, t := range tags {
<option value={ t.ID }>{ t.Name }</option>
}
</select>
</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",
Attributes: templ.Attributes{"required": "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",
})
</div>
<div class="flex justify-end">
@button.Button(button.Props{Type: button.TypeSubmit}) {
Save
}
</div>
</form>
}
templ EditBudgetForm(spaceID string, b *model.BudgetWithSpent, tags []*model.Tag) {
{{ editDialogID := "edit-budget-" + b.ID }}
<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-tag-" + b.ID}) {
Tag
}
<select name="tag_id" id={ "edit-budget-tag-" + b.ID } class="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background" required>
for _, t := range tags {
<option value={ t.ID } selected?={ t.ID == b.TagID }>{ t.Name }</option>
}
</select>
</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: fmt.Sprintf("%.2f", float64(b.AmountCents)/100.0),
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,
Attributes: templ.Attributes{"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,
})
} else {
@datepicker.DatePicker(datepicker.Props{
ID: "edit-budget-end-" + b.ID,
Name: "end_date",
})
}
</div>
<div class="flex justify-end">
@button.Button(button.Props{Type: button.TypeSubmit}) {
Save
}
</div>
</form>
}

View file

@ -112,7 +112,12 @@ templ ExpensesListContent(spaceID string, expenses []*model.ExpenseWithTagsAndMe
templ ExpenseListItem(spaceID string, exp *model.ExpenseWithTagsAndMethod, methods []*model.PaymentMethod) {
<div id={ "expense-" + exp.ID } class="p-4 flex justify-between items-start gap-2">
<div class="min-w-0 flex-1">
<p class="font-medium">{ exp.Description }</p>
<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 {

View file

@ -0,0 +1,47 @@
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)
}
</div>
</div>
</div>
}
}

View file

@ -0,0 +1,200 @@
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"
)
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>
// Date range selector
<div class="flex flex-wrap gap-2 items-center">
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", space.ID, 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", space.ID, p.Key),
"hx-target": "#report-content",
"hx-swap": "innerHTML",
},
}) {
{ p.Label }
}
}
}
</div>
<div id="report-content">
@ReportCharts(space.ID, report, presets[0].From, presets[0].To)
</div>
</div>
}
}
templ ReportCharts(spaceID string, report *model.SpendingReport, from, to time.Time) {
<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">{ fmt.Sprintf("$%.2f", float64(report.TotalIncome)/100.0) }</span>
</div>
<div class="flex justify-between">
<span class="text-destructive font-medium">Expenses</span>
<span class="font-bold text-destructive">{ fmt.Sprintf("$%.2f", float64(report.TotalExpenses)/100.0) }</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 >= 0), templ.KV("text-destructive", report.NetBalance < 0) }>
{ fmt.Sprintf("$%.2f", float64(report.NetBalance)/100.0) }
</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] = float64(t.TotalAmount) / 100.0
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 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, float64(d.TotalCents)/100.0)
}
} else {
for _, m := range report.MonthlySpending {
timeLabels = append(timeLabels, m.Month)
timeData = append(timeData, float64(m.TotalCents)/100.0)
}
}
}}
@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">
{ fmt.Sprintf("$%.2f", float64(exp.AmountCents)/100.0) }
</p>
</div>
}
</div>
}
</div>
</div>
}