Add category details page
This commit is contained in:
parent
978de59d36
commit
e8d3719adc
9 changed files with 301 additions and 12 deletions
|
@ -7,6 +7,7 @@ import com.wbrawner.twigs.service.category.CategoryService
|
|||
import com.wbrawner.twigs.service.transaction.TransactionService
|
||||
import com.wbrawner.twigs.service.user.UserService
|
||||
import com.wbrawner.twigs.web.budget.budgetWebRoutes
|
||||
import com.wbrawner.twigs.web.category.categoryWebRoutes
|
||||
import com.wbrawner.twigs.web.user.userWebRoutes
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.http.content.*
|
||||
|
@ -43,5 +44,6 @@ fun Application.webRoutes(
|
|||
}
|
||||
}
|
||||
budgetWebRoutes(budgetService, categoryService, transactionService, userService)
|
||||
categoryWebRoutes(budgetService, categoryService, transactionService, userService)
|
||||
userWebRoutes(userService)
|
||||
}
|
|
@ -1,9 +1,9 @@
|
|||
package com.wbrawner.twigs.web.budget
|
||||
|
||||
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.web.AuthenticatedPage
|
||||
import com.wbrawner.twigs.web.category.CategoryWithBalanceResponse
|
||||
|
||||
data class BudgetListPage(
|
||||
val budgets: List<BudgetListItem>,
|
||||
|
@ -26,14 +26,6 @@ data class BudgetDetailsPage(
|
|||
override val error: String? = null
|
||||
) : AuthenticatedPage {
|
||||
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(
|
||||
|
|
|
@ -12,6 +12,7 @@ import com.wbrawner.twigs.service.transaction.TransactionService
|
|||
import com.wbrawner.twigs.service.user.UserService
|
||||
import com.wbrawner.twigs.toInstantOrNull
|
||||
import com.wbrawner.twigs.web.NotFoundPage
|
||||
import com.wbrawner.twigs.web.category.CategoryWithBalanceResponse
|
||||
import com.wbrawner.twigs.web.user.TWIGS_SESSION_COOKIE
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.application.*
|
||||
|
@ -103,7 +104,7 @@ fun Application.budgetWebRoutes(
|
|||
.map { category ->
|
||||
val categoryBalance =
|
||||
abs(transactionService.sum(categoryId = category.id, userId = user.id))
|
||||
BudgetDetailsPage.CategoryWithBalanceResponse(
|
||||
CategoryWithBalanceResponse(
|
||||
category = category,
|
||||
amountLabel = category.amount.toCurrencyString(numberFormat),
|
||||
balance = categoryBalance,
|
||||
|
@ -225,6 +226,6 @@ private fun <T> MutableCollection<T>.extractIf(predicate: (T) -> Boolean): List<
|
|||
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)
|
||||
)
|
|
@ -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,
|
||||
)
|
|
@ -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"),
|
||||
)
|
|
@ -60,6 +60,8 @@ main {
|
|||
height: 100%;
|
||||
overflow-y: auto;
|
||||
flex-grow: 1;
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
|
|
36
web/src/main/resources/templates/category-details.mustache
Normal file
36
web/src/main/resources/templates/category-details.mustache
Normal 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}}
|
|
@ -1,5 +1,5 @@
|
|||
<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">
|
||||
<span class="body-large">{{category.title}}</span>
|
||||
<span class="body-small">{{remainingAmountLabel}} remaining</span>
|
||||
|
|
|
@ -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}}
|
Loading…
Reference in a new issue