From e8d3719adc0c300d085c38820c1d794de72c256c Mon Sep 17 00:00:00 2001 From: William Brawner Date: Fri, 5 Apr 2024 22:23:17 -0600 Subject: [PATCH] Add category details page --- .../com/wbrawner/twigs/web/WebRoutes.kt | 2 + .../wbrawner/twigs/web/budget/BudgetPages.kt | 10 +- .../twigs/web/budget/BudgetWebRoutes.kt | 5 +- .../twigs/web/category/CategoryPages.kt | 58 ++++++ .../twigs/web/category/CategoryWebRoutes.kt | 180 ++++++++++++++++++ web/src/main/resources/static/style.css | 2 + .../templates/category-details.mustache | 36 ++++ .../templates/partials/category-list.mustache | 2 +- .../partials/transaction-list.mustache | 18 ++ 9 files changed, 301 insertions(+), 12 deletions(-) create mode 100644 web/src/main/kotlin/com/wbrawner/twigs/web/category/CategoryPages.kt create mode 100644 web/src/main/kotlin/com/wbrawner/twigs/web/category/CategoryWebRoutes.kt create mode 100644 web/src/main/resources/templates/category-details.mustache create mode 100644 web/src/main/resources/templates/partials/transaction-list.mustache diff --git a/web/src/main/kotlin/com/wbrawner/twigs/web/WebRoutes.kt b/web/src/main/kotlin/com/wbrawner/twigs/web/WebRoutes.kt index bcb417c..d957e1a 100644 --- a/web/src/main/kotlin/com/wbrawner/twigs/web/WebRoutes.kt +++ b/web/src/main/kotlin/com/wbrawner/twigs/web/WebRoutes.kt @@ -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) } \ No newline at end of file diff --git a/web/src/main/kotlin/com/wbrawner/twigs/web/budget/BudgetPages.kt b/web/src/main/kotlin/com/wbrawner/twigs/web/budget/BudgetPages.kt index 70165e1..86c44ae 100644 --- a/web/src/main/kotlin/com/wbrawner/twigs/web/budget/BudgetPages.kt +++ b/web/src/main/kotlin/com/wbrawner/twigs/web/budget/BudgetPages.kt @@ -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, @@ -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( diff --git a/web/src/main/kotlin/com/wbrawner/twigs/web/budget/BudgetWebRoutes.kt b/web/src/main/kotlin/com/wbrawner/twigs/web/budget/BudgetWebRoutes.kt index 3ccbd35..244c34e 100644 --- a/web/src/main/kotlin/com/wbrawner/twigs/web/budget/BudgetWebRoutes.kt +++ b/web/src/main/kotlin/com/wbrawner/twigs/web/budget/BudgetWebRoutes.kt @@ -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 MutableCollection.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) ) \ No newline at end of file diff --git a/web/src/main/kotlin/com/wbrawner/twigs/web/category/CategoryPages.kt b/web/src/main/kotlin/com/wbrawner/twigs/web/category/CategoryPages.kt new file mode 100644 index 0000000..d1759cb --- /dev/null +++ b/web/src/main/kotlin/com/wbrawner/twigs/web/category/CategoryPages.kt @@ -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, + val transactionCount: String, + val transactions: List>>, + 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, +) \ No newline at end of file diff --git a/web/src/main/kotlin/com/wbrawner/twigs/web/category/CategoryWebRoutes.kt b/web/src/main/kotlin/com/wbrawner/twigs/web/category/CategoryWebRoutes.kt new file mode 100644 index 0000000..1794aa1 --- /dev/null +++ b/web/src/main/kotlin/com/wbrawner/twigs/web/category/CategoryWebRoutes.kt @@ -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"), +) \ No newline at end of file diff --git a/web/src/main/resources/static/style.css b/web/src/main/resources/static/style.css index 8ce3f75..3b31a96 100644 --- a/web/src/main/resources/static/style.css +++ b/web/src/main/resources/static/style.css @@ -60,6 +60,8 @@ main { height: 100%; overflow-y: auto; flex-grow: 1; + padding: 1rem; + box-sizing: border-box; } .sidebar { diff --git a/web/src/main/resources/templates/category-details.mustache b/web/src/main/resources/templates/category-details.mustache new file mode 100644 index 0000000..1766199 --- /dev/null +++ b/web/src/main/resources/templates/category-details.mustache @@ -0,0 +1,36 @@ +{{> partials/head }} +
+ {{>partials/sidebar}} +
+
+

{{category.category.title}}

+

{{category.category.description}}

+
+
+

Budgeted

+

{{category.amountLabel}}

+
+
+

Actual

+

{{category.balanceLabel}}

+
+
+

Remaining

+

{{category.remainingAmountLabel}}

+
+
+ {{category.balanceLabel}} +
+ +
+

Transactions

+
    + {{#transactions}} + {{>partials/transaction-list}} + {{/transactions}} +
+
+
+
+{{>partials/foot}} \ No newline at end of file diff --git a/web/src/main/resources/templates/partials/category-list.mustache b/web/src/main/resources/templates/partials/category-list.mustache index 0af4144..a9de2c9 100644 --- a/web/src/main/resources/templates/partials/category-list.mustache +++ b/web/src/main/resources/templates/partials/category-list.mustache @@ -1,5 +1,5 @@
  • - +
    {{category.title}} {{remainingAmountLabel}} remaining diff --git a/web/src/main/resources/templates/partials/transaction-list.mustache b/web/src/main/resources/templates/partials/transaction-list.mustache new file mode 100644 index 0000000..dba41f1 --- /dev/null +++ b/web/src/main/resources/templates/partials/transaction-list.mustache @@ -0,0 +1,18 @@ +
  • +
    + {{key}} +
    +
  • +{{#value}} +
  • + +
    +
    + {{title}} + {{description}} +
    + {{amountLabel}} +
    +
    +
  • +{{/value}} \ No newline at end of file