Add transaction details page

This commit is contained in:
William Brawner 2024-04-06 15:14:47 -06:00
parent e8d3719adc
commit fed06cd155
12 changed files with 329 additions and 8 deletions

View file

@ -8,6 +8,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.web.budget.budgetWebRoutes import com.wbrawner.twigs.web.budget.budgetWebRoutes
import com.wbrawner.twigs.web.category.categoryWebRoutes import com.wbrawner.twigs.web.category.categoryWebRoutes
import com.wbrawner.twigs.web.transaction.transactionWebRoutes
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.*
@ -45,5 +46,6 @@ fun Application.webRoutes(
} }
budgetWebRoutes(budgetService, categoryService, transactionService, userService) budgetWebRoutes(budgetService, categoryService, transactionService, userService)
categoryWebRoutes(budgetService, categoryService, transactionService, userService) categoryWebRoutes(budgetService, categoryService, transactionService, userService)
transactionWebRoutes(budgetService, categoryService, transactionService, userService)
userWebRoutes(userService) userWebRoutes(userService)
} }

View file

@ -22,10 +22,11 @@ data class BudgetDetailsPage(
val archivedIncomeCategories: List<CategoryWithBalanceResponse>, val archivedIncomeCategories: List<CategoryWithBalanceResponse>,
val archivedExpenseCategories: List<CategoryWithBalanceResponse>, val archivedExpenseCategories: List<CategoryWithBalanceResponse>,
val transactionCount: String, val transactionCount: String,
val monthAndYear: String,
override val user: UserResponse, override val user: UserResponse,
override val error: String? = null override val error: String? = null
) : AuthenticatedPage { ) : AuthenticatedPage {
override val title: String = "Budgets" override val title: String = budget.name.orEmpty()
} }
data class BudgetFormPage( data class BudgetFormPage(

View file

@ -25,6 +25,8 @@ import io.ktor.server.util.*
import java.math.BigDecimal import java.math.BigDecimal
import java.math.RoundingMode import java.math.RoundingMode
import java.text.NumberFormat import java.text.NumberFormat
import java.time.YearMonth
import java.time.format.DateTimeFormatter
import java.util.* import java.util.*
import kotlin.math.abs import kotlin.math.abs
@ -157,6 +159,7 @@ fun Application.budgetWebRoutes(
archivedExpenseCategories = archivedExpenseCategories, archivedExpenseCategories = archivedExpenseCategories,
transactionCount = NumberFormat.getNumberInstance(Locale.US) transactionCount = NumberFormat.getNumberInstance(Locale.US)
.format(transactions.size), .format(transactions.size),
monthAndYear = YearMonth.now().format(DateTimeFormatter.ofPattern("MMMM yyyy")),
user = user user = user
) )
) )

View file

@ -10,6 +10,7 @@ import java.text.NumberFormat
data class CategoryDetailsPage( data class CategoryDetailsPage(
val category: CategoryWithBalanceResponse, val category: CategoryWithBalanceResponse,
val budget: BudgetResponse,
val budgets: List<BudgetResponse>, val budgets: List<BudgetResponse>,
val transactionCount: String, val transactionCount: String,
val transactions: List<Map.Entry<String, List<TransactionListItem>>>, val transactions: List<Map.Entry<String, List<TransactionListItem>>>,
@ -39,6 +40,7 @@ fun TransactionResponse.toListItem(numberFormat: NumberFormat) = TransactionList
data class CategoryFormPage( data class CategoryFormPage(
val category: CategoryResponse, val category: CategoryResponse,
val budget: BudgetResponse,
override val user: UserResponse, override val user: UserResponse,
override val error: String? = null override val error: String? = null
) : AuthenticatedPage { ) : AuthenticatedPage {

View file

@ -60,7 +60,8 @@ fun Application.categoryWebRoutes(
expense = true, expense = true,
archived = false, archived = false,
), ),
user budget = budgetService.budget(budgetId = budgetId, userId = user.id),
user = user
) )
) )
) )
@ -88,8 +89,9 @@ fun Application.categoryWebRoutes(
archived = call.parameters["archived"]?.toBoolean() ?: false, archived = call.parameters["archived"]?.toBoolean() ?: false,
budgetId = budgetId budgetId = budgetId
), ),
user, budget = budgetService.budget(budgetId = budgetId, userId = user.id),
e.message user = user,
error = e.message
) )
) )
) )
@ -130,13 +132,17 @@ fun Application.categoryWebRoutes(
.mapValues { (_, transactions) -> transactions.map { it.toListItem(numberFormat) } } .mapValues { (_, transactions) -> transactions.map { it.toListItem(numberFormat) } }
.entries .entries
.sortedBy { it.key } .sortedBy { it.key }
val budgets = budgetService.budgetsForUser(user.id)
val budgetId = call.parameters.getOrFail("budgetId")
val budget = budgets.first { it.id == budgetId }
call.respond( call.respond(
MustacheContent( MustacheContent(
"category-details.mustache", CategoryDetailsPage( "category-details.mustache", CategoryDetailsPage(
category = categoryWithBalance, category = categoryWithBalance,
transactions = transactionsByDate, transactions = transactionsByDate,
transactionCount = transactionCount, transactionCount = transactionCount,
budgets = budgetService.budgetsForUser(user.id), budgets = budgets,
budget = budget,
user = user user = user
) )
) )
@ -156,10 +162,12 @@ fun Application.categoryWebRoutes(
categoryId = call.parameters.getOrFail("id"), categoryId = call.parameters.getOrFail("id"),
userId = user.id userId = user.id
) )
val budgetId = call.parameters.getOrFail("budgetId")
val budget = budgetService.budget(budgetId = budgetId, userId = user.id)
call.respond( call.respond(
MustacheContent( MustacheContent(
"category-form.mustache", "category-form.mustache",
CategoryFormPage(category, user) CategoryFormPage(category, budget, user)
) )
) )
} }

View file

@ -0,0 +1,36 @@
package com.wbrawner.twigs.web.transaction
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
data class TransactionDetailsPage(
val transaction: TransactionResponse,
val category: CategoryResponse?,
val budget: BudgetResponse,
val amountLabel: String,
val dateLabel: String,
val budgets: List<BudgetResponse>,
val createdBy: UserResponse,
override val user: UserResponse,
override val error: String? = null
) : AuthenticatedPage {
override val title: String = transaction.title.orEmpty()
}
data class TransactionFormPage(
val transaction: TransactionResponse,
val budget: BudgetResponse,
val incomeCategories: List<CategoryResponse>,
val expenseCategories: List<CategoryResponse>,
override val user: UserResponse,
override val error: String? = null
) : AuthenticatedPage {
override val title: String = if (transaction.id.isBlank()) {
"New Category"
} else {
"Edit Category"
}
}

View file

@ -0,0 +1,190 @@
package com.wbrawner.twigs.web.transaction
import com.wbrawner.twigs.service.HttpException
import com.wbrawner.twigs.service.budget.BudgetService
import com.wbrawner.twigs.service.category.CategoryService
import com.wbrawner.twigs.service.requireSession
import com.wbrawner.twigs.service.transaction.TransactionRequest
import com.wbrawner.twigs.service.transaction.TransactionResponse
import com.wbrawner.twigs.service.transaction.TransactionService
import com.wbrawner.twigs.service.user.UserService
import com.wbrawner.twigs.toInstant
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 java.text.NumberFormat
import java.time.Instant
import java.time.ZoneOffset.UTC
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
import java.util.*
private val dateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT)
private val numberFormat = NumberFormat.getCurrencyInstance(Locale.US)
fun Application.transactionWebRoutes(
budgetService: BudgetService,
categoryService: CategoryService,
transactionService: TransactionService,
userService: UserService
) {
routing {
authenticate(TWIGS_SESSION_COOKIE) {
route("/budgets/{budgetId}/transactions") {
get {
// TODO: Show transaction list here
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(
"transaction-form.mustache",
TransactionFormPage(
TransactionResponse(
id = "",
title = "",
description = "",
amount = 0,
budgetId = budgetId,
expense = true,
date = dateTimeFormatter.format(Instant.now()),
categoryId = null,
createdBy = user.id
),
budget = budgetService.budget(budgetId = budgetId, userId = user.id),
incomeCategories = categoryService.categories(
budgetIds = listOf(budgetId),
userId = user.id,
expense = false,
archived = false
),
expenseCategories = categoryService.categories(
budgetIds = listOf(budgetId),
userId = user.id,
expense = true,
archived = false
),
user
)
)
)
}
post {
val user = userService.user(requireSession().userId)
val budgetId = call.parameters.getOrFail("budgetId")
try {
val request = call.receiveParameters().toTransactionRequest()
val transaction = transactionService.save(request, user.id)
call.respondRedirect("/budgets/${transaction.budgetId}/categories/${transaction.id}")
} catch (e: HttpException) {
call.respond(
status = e.statusCode,
MustacheContent(
"transaction-form.mustache",
TransactionFormPage(
TransactionResponse(
id = "",
title = call.parameters["title"],
description = call.parameters["description"],
amount = call.parameters["amount"]?.toLongOrNull(),
budgetId = budgetId,
expense = call.parameters["expense"]?.toBoolean() ?: true,
date = call.parameters["date"].orEmpty(),
categoryId = call.parameters["categoryId"],
createdBy = user.id
),
budget = budgetService.budget(budgetId = budgetId, userId = user.id),
incomeCategories = categoryService.categories(
budgetIds = listOf(budgetId),
userId = user.id,
expense = false,
archived = false
),
expenseCategories = categoryService.categories(
budgetIds = listOf(budgetId),
userId = user.id,
expense = true,
archived = false
),
user
)
)
)
}
}
}
route("/{id}") {
get {
val user = userService.user(requireSession().userId)
val transactionId = call.parameters.getOrFail("id")
val budgetId = call.parameters.getOrFail("budgetId")
// TODO: Allow user-configurable locale
try {
val transaction = transactionService.transaction(
transactionId = transactionId,
userId = user.id
)
check(transaction.budgetId == budgetId) {
// TODO: redirect instead of error?
"Attempted to fetch transaction from wrong budget"
}
val category = transaction.categoryId?.let {
categoryService.category(categoryId = it, userId = user.id)
}
val budgets = budgetService.budgetsForUser(user.id)
val budget = budgets.first { it.id == budgetId }
val dateFormat = DateTimeFormatter.ofPattern("H:mm a 'on' MMMM d, yyyy")
val transactionInstant = transaction.date.toInstant()
val transactionOffset = transactionInstant.atOffset(UTC)
val dateLabel = transactionOffset.format(dateFormat)
call.respond(
MustacheContent(
"transaction-details.mustache", TransactionDetailsPage(
transaction = transaction,
category = category,
budget = budget,
budgets = budgets,
amountLabel = transaction.amount?.toCurrencyString(numberFormat).orEmpty(),
dateLabel = dateLabel,
createdBy = userService.user(transaction.createdBy),
user = user
)
)
)
} catch (e: HttpException) {
call.respond(
status = e.statusCode,
MustacheContent("404.mustache", NotFoundPage)
)
}
}
}
}
}
}
}
private fun Parameters.toTransactionRequest() = TransactionRequest(
title = get("title"),
description = get("description"),
amount = get("amount")?.toLongOrNull(),
expense = get("expense")?.toBoolean(),
date = get("date"),
categoryId = get("categoryId"),
budgetId = get("budgetId"),
)

View file

@ -180,6 +180,7 @@ a {
text-decoration: none; text-decoration: none;
border-radius: var(--border-radius); border-radius: var(--border-radius);
padding: 0.5rem; padding: 0.5rem;
color: var(--color-on-background);
} }
.list-item > a:hover { .list-item > a:hover {

View file

@ -7,7 +7,7 @@
<div class="row"> <div class="row">
<div class="stacked-label"> <div class="stacked-label">
<p class="body-small">Month</p> <p class="body-small">Month</p>
<p class="body-large">TODO</p> <p class="body-large">{{monthAndYear}}</p>
</div> </div>
<div class="stacked-label"> <div class="stacked-label">
<p class="body-small">Cash Flow</p> <p class="body-small">Cash Flow</p>

View file

@ -2,7 +2,7 @@
{{#budgets}} {{#budgets}}
<li class="list-item"> <li class="list-item">
<a class="{{#selected}}selected{{/selected}}" href="/budgets/{{id}}"> <a class="{{#selected}}selected{{/selected}}" href="/budgets/{{id}}">
<span class="body-large">{{name}}</span> <span class="body-medium">{{name}}</span>
<span class="body-small">{{description}}</span> <span class="body-small">{{description}}</span>
</a> </a>
</li> </li>

View file

@ -1,4 +1,45 @@
<aside class="sidebar"> <aside class="sidebar">
<div class="logo" style="height: 100px; width: 100px;"></div> <div class="logo" style="height: 100px; width: 100px;"></div>
<ul>
<li class="list-item">
<a href="/budgets/{{budget.id}}">
Overview
</a>
</li>
<li class="list-item">
<a href="/budgets/{{budget.id}}/transactions">
Transactions
</a>
</li>
<li class="list-item">
<a href="/budgets/{{budget.id}}/recurring">
Recurring Transactions
</a>
</li>
</ul>
<hr/>
{{>budget-list}} {{>budget-list}}
<hr/>
<ul>
<li class="list-item">
<a href="/profile">
Profile
</a>
</li>
<li class="list-item">
<a href="/settings">
Settings
</a>
</li>
<li class="list-item">
<a href="/about">
About Twigs
</a>
</li>
<li class="list-item">
<a href="https://github.com/wbrawner/twigs">
Source Code
</a>
</li>
</ul>
</aside> </aside>

View file

@ -0,0 +1,37 @@
{{> partials/head }}
<div id="app">
{{>partials/sidebar}}
<main>
<h1>{{title}}</h1>
<div class="card">
<div class="row">
<span class="body-large">{{dateLabel}}</span>
<span class="{{#transaction.expense}}expense{{/transaction.expense}}{{^transaction.expense}}income{{/transaction.expense}} body-large">{{amountLabel}}</span>
</div>
<div class="column">
<p>{{transaction.description}}</p>
<ul>
<li class="list-item">
<a href="/budgets/{{budget.id}}/categories/{{category.id}}">
<span class="body-small">Category</span>
<span class="body-large">{{category.title}}</span>
</a>
</li>
<li class="list-item">
<a href="/budgets/{{budget.id}}">
<span class="body-small">Budget</span>
<span class="body-large">{{budget.name}}</span>
</a>
</li>
<li class="list-item">
<a href="/budgets/{{budget.id}}">
<span class="body-small">Created by</span>
<span class="body-large">{{createdBy.username}}</span>
</a>
</li>
</ul>
</div>
</div>
</main>
</div>
{{>partials/foot}}