Implement category creation/editing

This commit is contained in:
William Brawner 2024-04-17 21:48:37 -06:00
parent 59aa48817a
commit ef1deaf19b
7 changed files with 143 additions and 39 deletions

View 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))
}

View file

@ -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(

View file

@ -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
)

View file

@ -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))
}

View file

@ -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);
}

View file

@ -6,11 +6,18 @@
<header class="row">
<a id="hamburger" href="#sidebar">☰</a>
<h1>{{title}}</h1>
<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 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>

View 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}}