budgit/internal/ui/pages/app_space_overview.templ

385 lines
12 KiB
Text

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