feat: proper space overview page
All checks were successful
Deploy / build-and-deploy (push) Successful in 2m21s
All checks were successful
Deploy / build-and-deploy (push) Successful in 2m21s
This commit is contained in:
parent
03217ac69f
commit
f012766ec7
4 changed files with 459 additions and 5 deletions
|
|
@ -92,7 +92,75 @@ func (h *SpaceHandler) getTagForSpace(w http.ResponseWriter, spaceID, tagID stri
|
|||
return tag
|
||||
}
|
||||
|
||||
func (h *SpaceHandler) DashboardPage(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *SpaceHandler) OverviewPage(w http.ResponseWriter, r *http.Request) {
|
||||
spaceID := r.PathValue("spaceID")
|
||||
space, err := h.spaceService.GetSpace(spaceID)
|
||||
if err != nil {
|
||||
slog.Error("failed to get space", "error", err, "space_id", spaceID)
|
||||
http.Error(w, "Space not found.", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
balance, err := h.expenseService.GetBalanceForSpace(spaceID)
|
||||
if err != nil {
|
||||
slog.Error("failed to get balance", "error", err, "space_id", spaceID)
|
||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
allocated, err := h.accountService.GetTotalAllocatedForSpace(spaceID)
|
||||
if err != nil {
|
||||
slog.Error("failed to get total allocated", "error", err, "space_id", spaceID)
|
||||
allocated = 0
|
||||
}
|
||||
balance -= allocated
|
||||
|
||||
// This month's report
|
||||
now := time.Now()
|
||||
presets := service.GetPresetDateRanges(now)
|
||||
report, err := h.reportService.GetSpendingReport(spaceID, presets[0].From, presets[0].To)
|
||||
if err != nil {
|
||||
slog.Error("failed to get spending report", "error", err, "space_id", spaceID)
|
||||
report = nil
|
||||
}
|
||||
|
||||
// Budgets
|
||||
tags, err := h.tagService.GetTagsForSpace(spaceID)
|
||||
if err != nil {
|
||||
slog.Error("failed to get tags", "error", err, "space_id", spaceID)
|
||||
}
|
||||
var budgets []*model.BudgetWithSpent
|
||||
if tags != nil {
|
||||
budgets, err = h.budgetService.GetBudgetsWithSpent(spaceID, tags)
|
||||
if err != nil {
|
||||
slog.Error("failed to get budgets", "error", err, "space_id", spaceID)
|
||||
}
|
||||
}
|
||||
|
||||
// Recurring expenses
|
||||
recs, err := h.recurringService.GetRecurringExpensesWithTagsAndMethodsForSpace(spaceID)
|
||||
if err != nil {
|
||||
slog.Error("failed to get recurring expenses", "error", err, "space_id", spaceID)
|
||||
}
|
||||
|
||||
// Shopping lists
|
||||
cards, err := h.buildListCards(spaceID)
|
||||
if err != nil {
|
||||
slog.Error("failed to build list cards", "error", err, "space_id", spaceID)
|
||||
}
|
||||
|
||||
ui.Render(w, r, pages.SpaceOverviewPage(pages.OverviewData{
|
||||
Space: space,
|
||||
Balance: balance,
|
||||
Allocated: allocated,
|
||||
Report: report,
|
||||
Budgets: budgets,
|
||||
UpcomingRecurring: recs,
|
||||
ShoppingLists: cards,
|
||||
}))
|
||||
}
|
||||
|
||||
func (h *SpaceHandler) ReportsPage(w http.ResponseWriter, r *http.Request) {
|
||||
spaceID := r.PathValue("spaceID")
|
||||
space, err := h.spaceService.GetSpace(spaceID)
|
||||
if err != nil {
|
||||
|
|
@ -101,7 +169,6 @@ func (h *SpaceHandler) DashboardPage(w http.ResponseWriter, r *http.Request) {
|
|||
return
|
||||
}
|
||||
|
||||
// Default to this month
|
||||
now := time.Now()
|
||||
presets := service.GetPresetDateRanges(now)
|
||||
from := presets[0].From
|
||||
|
|
|
|||
|
|
@ -61,9 +61,13 @@ func SetupRoutes(a *app.App) http.Handler {
|
|||
mux.HandleFunc("POST /app/settings/password", authRateLimiter(middleware.RequireAuth(settings.SetPassword)))
|
||||
|
||||
// Space routes
|
||||
spaceDashboardHandler := middleware.RequireAuth(space.DashboardPage)
|
||||
spaceDashboardWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(spaceDashboardHandler)
|
||||
mux.Handle("GET /app/spaces/{spaceID}", spaceDashboardWithAccess)
|
||||
spaceOverviewHandler := middleware.RequireAuth(space.OverviewPage)
|
||||
spaceOverviewWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(spaceOverviewHandler)
|
||||
mux.Handle("GET /app/spaces/{spaceID}", spaceOverviewWithAccess)
|
||||
|
||||
reportsPageHandler := middleware.RequireAuth(space.ReportsPage)
|
||||
reportsPageWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(reportsPageHandler)
|
||||
mux.Handle("GET /app/spaces/{spaceID}/reports", reportsPageWithAccess)
|
||||
|
||||
listsPageHandler := middleware.RequireAuth(space.ListsPage)
|
||||
listsPageWithAccess := middleware.RequireSpaceAccess(a.SpaceService)(listsPageHandler)
|
||||
|
|
|
|||
|
|
@ -44,6 +44,16 @@ templ Space(title string, space *model.Space) {
|
|||
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||
Href: "/app/spaces/" + space.ID,
|
||||
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID,
|
||||
Tooltip: "Overview",
|
||||
}) {
|
||||
@icon.House(icon.Props{Class: "size-4"})
|
||||
<span>Overview</span>
|
||||
}
|
||||
}
|
||||
@sidebar.MenuItem() {
|
||||
@sidebar.MenuButton(sidebar.MenuButtonProps{
|
||||
Href: "/app/spaces/" + space.ID + "/reports",
|
||||
IsActive: ctxkeys.URLPath(ctx) == "/app/spaces/"+space.ID+"/reports",
|
||||
Tooltip: "Reports",
|
||||
}) {
|
||||
@icon.ChartPie(icon.Props{Class: "size-4"})
|
||||
|
|
|
|||
373
internal/ui/pages/app_space_overview.templ
Normal file
373
internal/ui/pages/app_space_overview.templ
Normal file
|
|
@ -0,0 +1,373 @@
|
|||
package pages
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"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"
|
||||
)
|
||||
|
||||
type OverviewData struct {
|
||||
Space *model.Space
|
||||
Balance int
|
||||
Allocated int
|
||||
Report *model.SpendingReport
|
||||
Budgets []*model.BudgetWithSpent
|
||||
UpcomingRecurring []*model.RecurringExpenseWithTagsAndMethod
|
||||
ShoppingLists []model.ListCardData
|
||||
}
|
||||
|
||||
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">
|
||||
<h3 class="font-semibold mb-3">Current Balance</h3>
|
||||
<p class={ "text-3xl font-bold", templ.KV("text-destructive", data.Balance < 0) }>
|
||||
{ fmt.Sprintf("$%.2f", float64(data.Balance)/100.0) }
|
||||
</p>
|
||||
if data.Allocated > 0 {
|
||||
<p class="text-sm text-muted-foreground mt-1">
|
||||
{ fmt.Sprintf("$%.2f", float64(data.Allocated)/100.0) } 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">{ fmt.Sprintf("$%.2f", float64(data.Report.TotalIncome)/100.0) }</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">{ fmt.Sprintf("$%.2f", float64(data.Report.TotalExpenses)/100.0) }</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 >= 0), templ.KV("text-destructive", data.Report.NetBalance < 0) }>
|
||||
{ fmt.Sprintf("$%.2f", float64(data.Report.NetBalance)/100.0) }
|
||||
</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] = float64(t.TotalAmount) / 100.0
|
||||
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, float64(d.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",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
} 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">
|
||||
if b.TagColor != nil {
|
||||
<span class="inline-block w-2.5 h-2.5 rounded-full" style={ "background-color: " + *b.TagColor }></span>
|
||||
}
|
||||
<span class="text-sm font-medium">{ b.TagName }</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>{ 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">
|
||||
<div class={ "h-2 rounded-full transition-all", overviewProgressBarColor(b.Status) } style={ fmt.Sprintf("width: %.1f%%", pct) }></div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
templ overviewRecurringCard(data OverviewData) {
|
||||
{{ upcoming := sortedActiveRecurring(data.UpcomingRecurring) }}
|
||||
<div class="border rounded-lg p-4 bg-card text-card-foreground">
|
||||
@overviewSectionHeader("Upcoming Recurring", "/app/spaces/"+data.Space.ID+"/recurring")
|
||||
if len(upcoming) == 0 {
|
||||
<p class="text-sm text-muted-foreground">No active recurring payments.</p>
|
||||
} else {
|
||||
<div class="divide-y">
|
||||
for i, r := range upcoming {
|
||||
if i < 5 {
|
||||
<div class="py-2 flex justify-between items-start gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-sm font-medium truncate">{ r.Description }</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{ overviewFrequencyLabel(r.Frequency) } · Next: { r.NextOccurrence.Format("Jan 02") }
|
||||
</p>
|
||||
</div>
|
||||
if r.Type == model.ExpenseTypeExpense {
|
||||
<p class="text-sm font-bold text-destructive shrink-0">
|
||||
{ fmt.Sprintf("$%.2f", float64(r.AmountCents)/100.0) }
|
||||
</p>
|
||||
} else {
|
||||
<p class="text-sm font-bold text-green-500 shrink-0">
|
||||
+{ fmt.Sprintf("$%.2f", float64(r.AmountCents)/100.0) }
|
||||
</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">
|
||||
{ fmt.Sprintf("$%.2f", float64(exp.AmountCents)/100.0) }
|
||||
</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>
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue