Add category details page

This commit is contained in:
William Brawner 2024-04-05 22:23:17 -06:00
parent 978de59d36
commit e8d3719adc
9 changed files with 301 additions and 12 deletions

View file

@ -7,6 +7,7 @@ import com.wbrawner.twigs.service.category.CategoryService
import com.wbrawner.twigs.service.transaction.TransactionService import com.wbrawner.twigs.service.transaction.TransactionService
import com.wbrawner.twigs.service.user.UserService import com.wbrawner.twigs.service.user.UserService
import com.wbrawner.twigs.web.budget.budgetWebRoutes import com.wbrawner.twigs.web.budget.budgetWebRoutes
import com.wbrawner.twigs.web.category.categoryWebRoutes
import com.wbrawner.twigs.web.user.userWebRoutes import com.wbrawner.twigs.web.user.userWebRoutes
import io.ktor.server.application.* import io.ktor.server.application.*
import io.ktor.server.http.content.* import io.ktor.server.http.content.*
@ -43,5 +44,6 @@ fun Application.webRoutes(
} }
} }
budgetWebRoutes(budgetService, categoryService, transactionService, userService) budgetWebRoutes(budgetService, categoryService, transactionService, userService)
categoryWebRoutes(budgetService, categoryService, transactionService, userService)
userWebRoutes(userService) userWebRoutes(userService)
} }

View file

@ -1,9 +1,9 @@
package com.wbrawner.twigs.web.budget package com.wbrawner.twigs.web.budget
import com.wbrawner.twigs.service.budget.BudgetResponse import com.wbrawner.twigs.service.budget.BudgetResponse
import com.wbrawner.twigs.service.category.CategoryResponse
import com.wbrawner.twigs.service.user.UserResponse import com.wbrawner.twigs.service.user.UserResponse
import com.wbrawner.twigs.web.AuthenticatedPage import com.wbrawner.twigs.web.AuthenticatedPage
import com.wbrawner.twigs.web.category.CategoryWithBalanceResponse
data class BudgetListPage( data class BudgetListPage(
val budgets: List<BudgetListItem>, val budgets: List<BudgetListItem>,
@ -26,14 +26,6 @@ data class BudgetDetailsPage(
override val error: String? = null override val error: String? = null
) : AuthenticatedPage { ) : AuthenticatedPage {
override val title: String = "Budgets" override val title: String = "Budgets"
data class CategoryWithBalanceResponse(
val category: CategoryResponse,
val amountLabel: String,
val balance: Long,
val balanceLabel: String,
val remainingAmountLabel: String,
)
} }
data class BudgetFormPage( data class BudgetFormPage(

View file

@ -12,6 +12,7 @@ import com.wbrawner.twigs.service.transaction.TransactionService
import com.wbrawner.twigs.service.user.UserService import com.wbrawner.twigs.service.user.UserService
import com.wbrawner.twigs.toInstantOrNull import com.wbrawner.twigs.toInstantOrNull
import com.wbrawner.twigs.web.NotFoundPage import com.wbrawner.twigs.web.NotFoundPage
import com.wbrawner.twigs.web.category.CategoryWithBalanceResponse
import com.wbrawner.twigs.web.user.TWIGS_SESSION_COOKIE import com.wbrawner.twigs.web.user.TWIGS_SESSION_COOKIE
import io.ktor.http.* import io.ktor.http.*
import io.ktor.server.application.* import io.ktor.server.application.*
@ -103,7 +104,7 @@ fun Application.budgetWebRoutes(
.map { category -> .map { category ->
val categoryBalance = val categoryBalance =
abs(transactionService.sum(categoryId = category.id, userId = user.id)) abs(transactionService.sum(categoryId = category.id, userId = user.id))
BudgetDetailsPage.CategoryWithBalanceResponse( CategoryWithBalanceResponse(
category = category, category = category,
amountLabel = category.amount.toCurrencyString(numberFormat), amountLabel = category.amount.toCurrencyString(numberFormat),
balance = categoryBalance, balance = categoryBalance,
@ -225,6 +226,6 @@ private fun <T> MutableCollection<T>.extractIf(predicate: (T) -> Boolean): List<
return extracted return extracted
} }
private fun Long.toCurrencyString(formatter: NumberFormat): String = formatter.format( fun Long.toCurrencyString(formatter: NumberFormat): String = formatter.format(
this.toBigDecimal().divide(BigDecimal(100), 2, RoundingMode.HALF_UP) this.toBigDecimal().divide(BigDecimal(100), 2, RoundingMode.HALF_UP)
) )

View file

@ -0,0 +1,58 @@
package com.wbrawner.twigs.web.category
import com.wbrawner.twigs.service.budget.BudgetResponse
import com.wbrawner.twigs.service.category.CategoryResponse
import com.wbrawner.twigs.service.transaction.TransactionResponse
import com.wbrawner.twigs.service.user.UserResponse
import com.wbrawner.twigs.web.AuthenticatedPage
import com.wbrawner.twigs.web.budget.toCurrencyString
import java.text.NumberFormat
data class CategoryDetailsPage(
val category: CategoryWithBalanceResponse,
val budgets: List<BudgetResponse>,
val transactionCount: String,
val transactions: List<Map.Entry<String, List<TransactionListItem>>>,
override val user: UserResponse,
override val error: String? = null
) : AuthenticatedPage {
override val title: String = category.category.title
}
data class TransactionListItem(
val id: String,
val title: String,
val description: String,
val budgetId: String,
val expenseClass: String,
val amountLabel: String
)
fun TransactionResponse.toListItem(numberFormat: NumberFormat) = TransactionListItem(
id,
title.orEmpty(),
description.orEmpty(),
budgetId,
if (expense != false) "expense" else "income",
(amount ?: 0L).toCurrencyString(numberFormat)
)
data class CategoryFormPage(
val category: CategoryResponse,
override val user: UserResponse,
override val error: String? = null
) : AuthenticatedPage {
override val title: String = if (category.id.isBlank()) {
"New Category"
} else {
"Edit Category"
}
}
data class CategoryWithBalanceResponse(
val category: CategoryResponse,
val amountLabel: String,
val balance: Long,
val balanceLabel: String,
val remainingAmountLabel: String,
)

View file

@ -0,0 +1,180 @@
package com.wbrawner.twigs.web.category
import com.wbrawner.twigs.endOfMonth
import com.wbrawner.twigs.firstOfMonth
import com.wbrawner.twigs.service.HttpException
import com.wbrawner.twigs.service.budget.BudgetService
import com.wbrawner.twigs.service.category.CategoryRequest
import com.wbrawner.twigs.service.category.CategoryResponse
import com.wbrawner.twigs.service.category.CategoryService
import com.wbrawner.twigs.service.requireSession
import com.wbrawner.twigs.service.transaction.TransactionService
import com.wbrawner.twigs.service.user.UserService
import com.wbrawner.twigs.toInstant
import com.wbrawner.twigs.toInstantOrNull
import com.wbrawner.twigs.web.NotFoundPage
import com.wbrawner.twigs.web.budget.toCurrencyString
import com.wbrawner.twigs.web.user.TWIGS_SESSION_COOKIE
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.auth.*
import io.ktor.server.mustache.*
import io.ktor.server.request.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.util.*
import io.ktor.util.date.*
import java.text.DateFormat
import java.text.NumberFormat
import java.util.*
import kotlin.math.abs
fun Application.categoryWebRoutes(
budgetService: BudgetService,
categoryService: CategoryService,
transactionService: TransactionService,
userService: UserService
) {
routing {
authenticate(TWIGS_SESSION_COOKIE) {
route("/budgets/{budgetId}/categories") {
get {
val budgetId = call.parameters.getOrFail("budgetId")
call.respondRedirect("/budgets/$budgetId")
}
route("/new") {
get {
val user = userService.user(requireSession().userId)
val budgetId = call.parameters.getOrFail("budgetId")
call.respond(
MustacheContent(
"category-form.mustache",
CategoryFormPage(
CategoryResponse(
id = "",
title = "",
description = "",
amount = 0,
budgetId = budgetId,
expense = true,
archived = false,
),
user
)
)
)
}
post {
val user = userService.user(requireSession().userId)
val budgetId = call.parameters.getOrFail("budgetId")
try {
val request = call.receiveParameters().toCategoryRequest()
val category = categoryService.save(request, user.id)
call.respondRedirect("/budgets/${category.budgetId}/categories/${category.id}")
} catch (e: HttpException) {
call.respond(
status = e.statusCode,
MustacheContent(
"category-form.mustache",
CategoryFormPage(
CategoryResponse(
id = "",
title = call.parameters["title"].orEmpty(),
description = call.parameters["description"].orEmpty(),
amount = call.parameters["amount"]?.toLongOrNull() ?: 0L,
expense = call.parameters["expense"]?.toBoolean() ?: false,
archived = call.parameters["archived"]?.toBoolean() ?: false,
budgetId = budgetId
),
user,
e.message
)
)
)
}
}
}
route("/{id}") {
get {
val user = userService.user(requireSession().userId)
val categoryId = call.parameters.getOrFail("id")
// TODO: Allow user-configurable locale
val numberFormat = NumberFormat.getCurrencyInstance(Locale.US)
try {
val category = categoryService.category(categoryId = categoryId, userId = user.id)
val categoryBalance =
abs(transactionService.sum(categoryId = category.id, userId = user.id))
val categoryWithBalance = CategoryWithBalanceResponse(
category = category,
amountLabel = category.amount.toCurrencyString(numberFormat),
balance = categoryBalance,
balanceLabel = categoryBalance.toCurrencyString(numberFormat),
remainingAmountLabel = (category.amount - categoryBalance).toCurrencyString(numberFormat)
)
val transactions = transactionService.transactions(
budgetIds = listOf(category.budgetId),
categoryIds = listOf(category.id),
from = call.parameters["from"]?.toInstantOrNull() ?: firstOfMonth,
to = call.parameters["to"]?.toInstantOrNull() ?: endOfMonth,
userId = user.id
)
val transactionCount = NumberFormat.getNumberInstance(Locale.US)
.format(transactions.size)
val dateFormat = DateFormat.getDateInstance(DateFormat.SHORT, Locale.US)
val transactionsByDate = transactions.groupBy {
dateFormat.format(it.date.toInstant().toGMTDate().toJvmDate())
}
.mapValues { (_, transactions) -> transactions.map { it.toListItem(numberFormat) } }
.entries
.sortedBy { it.key }
call.respond(
MustacheContent(
"category-details.mustache", CategoryDetailsPage(
category = categoryWithBalance,
transactions = transactionsByDate,
transactionCount = transactionCount,
budgets = budgetService.budgetsForUser(user.id),
user = user
)
)
)
} catch (e: HttpException) {
call.respond(
status = e.statusCode,
MustacheContent("404.mustache", NotFoundPage)
)
}
}
route("/edit") {
get {
val user = userService.user(requireSession().userId)
val category = categoryService.category(
categoryId = call.parameters.getOrFail("id"),
userId = user.id
)
call.respond(
MustacheContent(
"category-form.mustache",
CategoryFormPage(category, user)
)
)
}
}
}
}
}
}
}
private fun Parameters.toCategoryRequest() = CategoryRequest(
title = get("title"),
description = get("description"),
amount = get("amount")?.toLongOrNull(),
expense = get("expense")?.toBoolean(),
archived = get("archived")?.toBoolean(),
budgetId = get("budgetId"),
)

View file

@ -60,6 +60,8 @@ main {
height: 100%; height: 100%;
overflow-y: auto; overflow-y: auto;
flex-grow: 1; flex-grow: 1;
padding: 1rem;
box-sizing: border-box;
} }
.sidebar { .sidebar {

View file

@ -0,0 +1,36 @@
{{> partials/head }}
<div id="app">
{{>partials/sidebar}}
<main>
<div class="column">
<h1>{{category.category.title}}</h1>
<p>{{category.category.description}}</p>
<div class="row">
<div class="stacked-label">
<p class="body-small">Budgeted</p>
<p class="body-large">{{category.amountLabel}}</p>
</div>
<div class="stacked-label">
<p class="body-small">Actual</p>
<p class="body-large">{{category.balanceLabel}}</p>
</div>
<div class="stacked-label">
<p class="body-small">Remaining</p>
<p class="body-large">{{category.remainingAmountLabel}}</p>
</div>
</div>
<progress value="{{category.balance}}"
max="{{category.category.amount}}">{{category.balanceLabel}}</progress>
</div>
<!-- TODO: Add a search bar to filter transactions by name/description -->
<div class="card">
<h3>Transactions</h3>
<ul>
{{#transactions}}
{{>partials/transaction-list}}
{{/transactions}}
</ul>
</div>
</main>
</div>
{{>partials/foot}}

View file

@ -1,5 +1,5 @@
<li class="list-item"> <li class="list-item">
<a href="/categories/{{category.id}}"> <a href="/budgets/{{category.budgetId}}/categories/{{category.id}}">
<div class="row" style="justify-content: space-between"> <div class="row" style="justify-content: space-between">
<span class="body-large">{{category.title}}</span> <span class="body-large">{{category.title}}</span>
<span class="body-small">{{remainingAmountLabel}} remaining</span> <span class="body-small">{{remainingAmountLabel}} remaining</span>

View file

@ -0,0 +1,18 @@
<li class="list-item">
<h5>
{{key}}
</h5>
</li>
{{#value}}
<li class="list-item">
<a href="/budgets/{{budgetId}}/transactions/{{id}}">
<div class="row" style="justify-content: space-between">
<div class="column">
<span class="body-large">{{title}}</span>
<span class="body-small">{{description}}</span>
</div>
<span class="body-medium {{expenseClass}}">{{amountLabel}}</span>
</div>
</a>
</li>
{{/value}}