Implement category creation/editing
This commit is contained in:
parent
59aa48817a
commit
ef1deaf19b
7 changed files with 143 additions and 39 deletions
30
web/src/main/kotlin/com/wbrawner/twigs/web/WebUtils.kt
Normal file
30
web/src/main/kotlin/com/wbrawner/twigs/web/WebUtils.kt
Normal file
|
@ -0,0 +1,30 @@
|
|||
package com.wbrawner.twigs.web
|
||||
|
||||
import io.ktor.http.*
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.text.DecimalFormat
|
||||
import java.text.NumberFormat
|
||||
import java.util.*
|
||||
|
||||
val currencyFormat = NumberFormat.getCurrencyInstance(Locale.US)
|
||||
val decimalFormat = DecimalFormat.getNumberInstance(Locale.US).apply {
|
||||
with(this as DecimalFormat) {
|
||||
decimalFormatSymbols = decimalFormatSymbols.apply {
|
||||
currencySymbol = ""
|
||||
isGroupingUsed = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Parameters.getAmount() = decimalFormat.parse(get("amount"))
|
||||
?.toDouble()
|
||||
?.toBigDecimal()
|
||||
?.times(BigDecimal(100))
|
||||
?.toLong()
|
||||
?: 0L
|
||||
|
||||
fun Long?.toDecimalString(): String {
|
||||
if (this == null) return ""
|
||||
return decimalFormat.format(toBigDecimal().divide(BigDecimal(100), 2, RoundingMode.HALF_UP))
|
||||
}
|
|
@ -40,6 +40,7 @@ fun TransactionResponse.toListItem(numberFormat: NumberFormat) = TransactionList
|
|||
|
||||
data class CategoryFormPage(
|
||||
val category: CategoryResponse,
|
||||
val amountLabel: String,
|
||||
val budget: BudgetResponse,
|
||||
override val user: UserResponse,
|
||||
override val error: String? = null
|
||||
|
@ -49,6 +50,10 @@ data class CategoryFormPage(
|
|||
} else {
|
||||
"Edit Category"
|
||||
}
|
||||
|
||||
val expenseChecked: String = if (category.expense) "checked" else ""
|
||||
val incomeChecked: String = if (!category.expense) "checked" else ""
|
||||
val archivedChecked: String = if (category.archived) "checked" else ""
|
||||
}
|
||||
|
||||
data class CategoryWithBalanceResponse(
|
||||
|
|
|
@ -14,6 +14,8 @@ 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.getAmount
|
||||
import com.wbrawner.twigs.web.toDecimalString
|
||||
import com.wbrawner.twigs.web.user.TWIGS_SESSION_COOKIE
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.application.*
|
||||
|
@ -57,9 +59,10 @@ fun Application.categoryWebRoutes(
|
|||
description = "",
|
||||
amount = 0,
|
||||
budgetId = budgetId,
|
||||
expense = true,
|
||||
expense = call.request.queryParameters["expense"]?.toBoolean() ?: true,
|
||||
archived = false,
|
||||
),
|
||||
amountLabel = 0L.toDecimalString(),
|
||||
budget = budgetService.budget(budgetId = budgetId, userId = user.id),
|
||||
user = user
|
||||
)
|
||||
|
@ -71,7 +74,7 @@ fun Application.categoryWebRoutes(
|
|||
val user = userService.user(requireSession().userId)
|
||||
val budgetId = call.parameters.getOrFail("budgetId")
|
||||
try {
|
||||
val request = call.receiveParameters().toCategoryRequest()
|
||||
val request = call.receiveParameters().toCategoryRequest(budgetId)
|
||||
val category = categoryService.save(request, user.id)
|
||||
call.respondRedirect("/budgets/${category.budgetId}/categories/${category.id}")
|
||||
} catch (e: HttpException) {
|
||||
|
@ -84,11 +87,12 @@ fun Application.categoryWebRoutes(
|
|||
id = "",
|
||||
title = call.parameters["title"].orEmpty(),
|
||||
description = call.parameters["description"].orEmpty(),
|
||||
amount = call.parameters["amount"]?.toLongOrNull() ?: 0L,
|
||||
amount = 0L,
|
||||
expense = call.parameters["expense"]?.toBoolean() ?: false,
|
||||
archived = call.parameters["archived"]?.toBoolean() ?: false,
|
||||
budgetId = budgetId
|
||||
),
|
||||
amountLabel = call.parameters["amount"]?.toLongOrNull().toDecimalString(),
|
||||
budget = budgetService.budget(budgetId = budgetId, userId = user.id),
|
||||
user = user,
|
||||
error = e.message
|
||||
|
@ -167,10 +171,43 @@ fun Application.categoryWebRoutes(
|
|||
call.respond(
|
||||
MustacheContent(
|
||||
"category-form.mustache",
|
||||
CategoryFormPage(category, budget, user)
|
||||
CategoryFormPage(category, category.amount.toDecimalString(), budget, user)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
post {
|
||||
val user = userService.user(requireSession().userId)
|
||||
val budgetId = call.parameters.getOrFail("budgetId")
|
||||
val categoryId = call.parameters.getOrFail("id")
|
||||
try {
|
||||
val request = call.receiveParameters().toCategoryRequest(budgetId)
|
||||
val category = categoryService.save(request, userId = user.id, categoryId = categoryId)
|
||||
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 = 0L,
|
||||
expense = call.parameters["expense"]?.toBoolean() ?: false,
|
||||
archived = call.parameters["archived"]?.toBoolean() ?: false,
|
||||
budgetId = budgetId
|
||||
),
|
||||
amountLabel = call.parameters["amount"]?.toLongOrNull().toDecimalString(),
|
||||
budget = budgetService.budget(budgetId = budgetId, userId = user.id),
|
||||
user = user,
|
||||
error = e.message
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -178,11 +215,11 @@ fun Application.categoryWebRoutes(
|
|||
}
|
||||
}
|
||||
|
||||
private fun Parameters.toCategoryRequest() = CategoryRequest(
|
||||
private fun Parameters.toCategoryRequest(budgetId: String) = CategoryRequest(
|
||||
title = get("title"),
|
||||
description = get("description"),
|
||||
amount = get("amount")?.toLongOrNull(),
|
||||
amount = getAmount(),
|
||||
expense = get("expense")?.toBoolean(),
|
||||
archived = get("archived")?.toBoolean(),
|
||||
budgetId = get("budgetId"),
|
||||
archived = get("archived") == "on",
|
||||
budgetId = budgetId
|
||||
)
|
|
@ -12,6 +12,9 @@ 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.currencyFormat
|
||||
import com.wbrawner.twigs.web.getAmount
|
||||
import com.wbrawner.twigs.web.toDecimalString
|
||||
import com.wbrawner.twigs.web.user.TWIGS_SESSION_COOKIE
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.application.*
|
||||
|
@ -21,27 +24,10 @@ import io.ktor.server.request.*
|
|||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import io.ktor.server.util.*
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.text.DecimalFormat
|
||||
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.time.temporal.ChronoUnit
|
||||
import java.util.*
|
||||
|
||||
private val dateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.SHORT)
|
||||
private val currencyFormat = NumberFormat.getCurrencyInstance(Locale.US)
|
||||
private val decimalFormat = DecimalFormat.getNumberInstance(Locale.US).apply {
|
||||
with(this as DecimalFormat) {
|
||||
decimalFormatSymbols = decimalFormatSymbols.apply {
|
||||
currencySymbol = ""
|
||||
isGroupingUsed = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun Application.transactionWebRoutes(
|
||||
budgetService: BudgetService,
|
||||
|
@ -62,6 +48,7 @@ fun Application.transactionWebRoutes(
|
|||
get {
|
||||
val user = userService.user(requireSession().userId)
|
||||
val budgetId = call.parameters.getOrFail("budgetId")
|
||||
val categoryId = call.request.queryParameters["categoryId"]
|
||||
val transaction = TransactionResponse(
|
||||
id = "",
|
||||
title = "",
|
||||
|
@ -70,7 +57,7 @@ fun Application.transactionWebRoutes(
|
|||
budgetId = budgetId,
|
||||
expense = true,
|
||||
date = Instant.now().toHtmlInputString(),
|
||||
categoryId = null,
|
||||
categoryId = categoryId,
|
||||
createdBy = user.id
|
||||
)
|
||||
call.respond(
|
||||
|
@ -78,9 +65,14 @@ fun Application.transactionWebRoutes(
|
|||
"transaction-form.mustache",
|
||||
TransactionFormPage(
|
||||
transaction = transaction,
|
||||
amountLabel = currencyFormat.format(0L),
|
||||
amountLabel = 0L.toDecimalString(),
|
||||
budget = budgetService.budget(budgetId = budgetId, userId = user.id),
|
||||
categoryOptions = categoryOptions(transaction, categoryService, budgetId, user),
|
||||
categoryOptions = categoryOptions(
|
||||
transaction = transaction,
|
||||
categoryService = categoryService,
|
||||
budgetId = budgetId,
|
||||
user = user
|
||||
),
|
||||
user = user
|
||||
)
|
||||
)
|
||||
|
@ -313,7 +305,7 @@ private suspend fun categoryOptions(
|
|||
private fun Parameters.toTransactionRequest() = TransactionRequest(
|
||||
title = get("title"),
|
||||
description = get("description"),
|
||||
amount = decimalFormat.parse(get("amount"))?.toDouble()?.toBigDecimal()?.times(BigDecimal(100))?.toLong() ?: 0L,
|
||||
amount = getAmount(),
|
||||
expense = false,
|
||||
date = get("date"),
|
||||
categoryId = get("categoryId"),
|
||||
|
@ -322,7 +314,3 @@ private fun Parameters.toTransactionRequest() = TransactionRequest(
|
|||
|
||||
private fun Instant.toHtmlInputString() = truncatedTo(ChronoUnit.MINUTES).toString().substringBefore(":00Z")
|
||||
|
||||
private fun Long?.toDecimalString(): String {
|
||||
if (this == null) return ""
|
||||
return decimalFormat.format(toBigDecimal().divide(BigDecimal(100), 2, RoundingMode.HALF_UP))
|
||||
}
|
|
@ -44,7 +44,7 @@ h1, h2, h3, h4, h5, h6, p, ul {
|
|||
padding: 0;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6, p, summary {
|
||||
h2, h3, h4, h5, h6, p, summary {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
|
@ -186,6 +186,12 @@ input:disabled {
|
|||
color: var(--color-on-background);
|
||||
}
|
||||
|
||||
.inline-input {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: start;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
|
|
@ -6,11 +6,18 @@
|
|||
<header class="row">
|
||||
<a id="hamburger" href="#sidebar">☰</a>
|
||||
<h1>{{title}}</h1>
|
||||
<div class="row">
|
||||
<a href="/budgets/{{budget.id}}/categories/{{category.category.id}}/edit"
|
||||
class="button button-secondary" style="margin-right: 10px;">
|
||||
<!-- TODO: Hide text on small widths -->
|
||||
<span aria-description="Edit Category">✎</span> <span aria-hidden="true">Edit Category</span>
|
||||
</a>
|
||||
<a href="/budgets/{{budget.id}}/transactions/new?categoryId={{category.category.id}}"
|
||||
class="button button-secondary">
|
||||
<!-- TODO: Hide text on small widths -->
|
||||
<span aria-description="New Transaction">+</span> <span aria-hidden="true">New Transaction</span>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
<p>{{category.category.description}}</p>
|
||||
</div>
|
||||
|
|
31
web/src/main/resources/templates/category-form.mustache
Normal file
31
web/src/main/resources/templates/category-form.mustache
Normal file
|
@ -0,0 +1,31 @@
|
|||
{{> partials/head }}
|
||||
<div id="app">
|
||||
<main>
|
||||
<h1>{{title}}</h1>
|
||||
{{#error }}
|
||||
<p class="error">{{error}}</p>
|
||||
{{/error}}
|
||||
<form method="post">
|
||||
<label for="title">Name</label>
|
||||
<input id="title" type="text" name="title" value="{{ category.title }}" required/>
|
||||
<label for="description">Description</label>
|
||||
<textarea id="description" name="description">{{ category.description }}</textarea>
|
||||
<label for="amount">Amount</label>
|
||||
<input id="amount" type="number" name="amount" value="{{ amountLabel }}" step="0.01" required/>
|
||||
<div class="inline-input">
|
||||
<input id="expense" type="radio" name="expense" value="true" {{expenseChecked}} />
|
||||
<label for="expense">Expense</label>
|
||||
</div>
|
||||
<div class="inline-input">
|
||||
<input id="income" type="radio" name="expense" value="false" {{incomeChecked}} />
|
||||
<label for="income">Income</label>
|
||||
</div>
|
||||
<div class="inline-input">
|
||||
<input id="archived" type="checkbox" name="archived" {{archivedChecked}} />
|
||||
<label for="archived">Archived</label>
|
||||
</div>
|
||||
<input id="submit" type="submit" class="button button-primary" value="Save"/>
|
||||
</form>
|
||||
</main>
|
||||
</div>
|
||||
{{>partials/foot}}
|
Loading…
Reference in a new issue