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.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)
|
||||||
}
|
}
|
|
@ -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(
|
||||||
|
|
|
@ -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)
|
||||||
)
|
)
|
|
@ -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%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
|
padding: 1rem;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.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">
|
<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>
|
||||||
|
|
|
@ -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