Add transaction details page
This commit is contained in:
parent
e8d3719adc
commit
fed06cd155
12 changed files with 329 additions and 8 deletions
|
@ -8,6 +8,7 @@ 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.transaction.transactionWebRoutes
|
||||
import com.wbrawner.twigs.web.user.userWebRoutes
|
||||
import io.ktor.server.application.*
|
||||
import io.ktor.server.http.content.*
|
||||
|
@ -45,5 +46,6 @@ fun Application.webRoutes(
|
|||
}
|
||||
budgetWebRoutes(budgetService, categoryService, transactionService, userService)
|
||||
categoryWebRoutes(budgetService, categoryService, transactionService, userService)
|
||||
transactionWebRoutes(budgetService, categoryService, transactionService, userService)
|
||||
userWebRoutes(userService)
|
||||
}
|
|
@ -22,10 +22,11 @@ data class BudgetDetailsPage(
|
|||
val archivedIncomeCategories: List<CategoryWithBalanceResponse>,
|
||||
val archivedExpenseCategories: List<CategoryWithBalanceResponse>,
|
||||
val transactionCount: String,
|
||||
val monthAndYear: String,
|
||||
override val user: UserResponse,
|
||||
override val error: String? = null
|
||||
) : AuthenticatedPage {
|
||||
override val title: String = "Budgets"
|
||||
override val title: String = budget.name.orEmpty()
|
||||
}
|
||||
|
||||
data class BudgetFormPage(
|
||||
|
|
|
@ -25,6 +25,8 @@ import io.ktor.server.util.*
|
|||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.text.NumberFormat
|
||||
import java.time.YearMonth
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.*
|
||||
import kotlin.math.abs
|
||||
|
||||
|
@ -157,6 +159,7 @@ fun Application.budgetWebRoutes(
|
|||
archivedExpenseCategories = archivedExpenseCategories,
|
||||
transactionCount = NumberFormat.getNumberInstance(Locale.US)
|
||||
.format(transactions.size),
|
||||
monthAndYear = YearMonth.now().format(DateTimeFormatter.ofPattern("MMMM yyyy")),
|
||||
user = user
|
||||
)
|
||||
)
|
||||
|
|
|
@ -10,6 +10,7 @@ import java.text.NumberFormat
|
|||
|
||||
data class CategoryDetailsPage(
|
||||
val category: CategoryWithBalanceResponse,
|
||||
val budget: BudgetResponse,
|
||||
val budgets: List<BudgetResponse>,
|
||||
val transactionCount: String,
|
||||
val transactions: List<Map.Entry<String, List<TransactionListItem>>>,
|
||||
|
@ -39,6 +40,7 @@ fun TransactionResponse.toListItem(numberFormat: NumberFormat) = TransactionList
|
|||
|
||||
data class CategoryFormPage(
|
||||
val category: CategoryResponse,
|
||||
val budget: BudgetResponse,
|
||||
override val user: UserResponse,
|
||||
override val error: String? = null
|
||||
) : AuthenticatedPage {
|
||||
|
|
|
@ -60,7 +60,8 @@ fun Application.categoryWebRoutes(
|
|||
expense = true,
|
||||
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,
|
||||
budgetId = budgetId
|
||||
),
|
||||
user,
|
||||
e.message
|
||||
budget = budgetService.budget(budgetId = budgetId, userId = user.id),
|
||||
user = user,
|
||||
error = e.message
|
||||
)
|
||||
)
|
||||
)
|
||||
|
@ -130,13 +132,17 @@ fun Application.categoryWebRoutes(
|
|||
.mapValues { (_, transactions) -> transactions.map { it.toListItem(numberFormat) } }
|
||||
.entries
|
||||
.sortedBy { it.key }
|
||||
val budgets = budgetService.budgetsForUser(user.id)
|
||||
val budgetId = call.parameters.getOrFail("budgetId")
|
||||
val budget = budgets.first { it.id == budgetId }
|
||||
call.respond(
|
||||
MustacheContent(
|
||||
"category-details.mustache", CategoryDetailsPage(
|
||||
category = categoryWithBalance,
|
||||
transactions = transactionsByDate,
|
||||
transactionCount = transactionCount,
|
||||
budgets = budgetService.budgetsForUser(user.id),
|
||||
budgets = budgets,
|
||||
budget = budget,
|
||||
user = user
|
||||
)
|
||||
)
|
||||
|
@ -156,10 +162,12 @@ fun Application.categoryWebRoutes(
|
|||
categoryId = call.parameters.getOrFail("id"),
|
||||
userId = user.id
|
||||
)
|
||||
val budgetId = call.parameters.getOrFail("budgetId")
|
||||
val budget = budgetService.budget(budgetId = budgetId, userId = user.id)
|
||||
call.respond(
|
||||
MustacheContent(
|
||||
"category-form.mustache",
|
||||
CategoryFormPage(category, user)
|
||||
CategoryFormPage(category, budget, user)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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"),
|
||||
)
|
|
@ -180,6 +180,7 @@ a {
|
|||
text-decoration: none;
|
||||
border-radius: var(--border-radius);
|
||||
padding: 0.5rem;
|
||||
color: var(--color-on-background);
|
||||
}
|
||||
|
||||
.list-item > a:hover {
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
<div class="row">
|
||||
<div class="stacked-label">
|
||||
<p class="body-small">Month</p>
|
||||
<p class="body-large">TODO</p>
|
||||
<p class="body-large">{{monthAndYear}}</p>
|
||||
</div>
|
||||
<div class="stacked-label">
|
||||
<p class="body-small">Cash Flow</p>
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
{{#budgets}}
|
||||
<li class="list-item">
|
||||
<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>
|
||||
</a>
|
||||
</li>
|
||||
|
|
|
@ -1,4 +1,45 @@
|
|||
<aside class="sidebar">
|
||||
<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}}
|
||||
<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>
|
|
@ -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}}
|
Loading…
Reference in a new issue