From 59aa48817a30598f82128ddc26d1471612f696d9 Mon Sep 17 00:00:00 2001 From: William Brawner Date: Tue, 16 Apr 2024 21:14:35 -0600 Subject: [PATCH] More progress on transaction editing, also removed some debug logging for hikari and set the docker compose file to use postgres --- .../com/wbrawner/twigs/server/Application.kt | 3 - docker-compose.yml | 1 + .../com/wbrawner/twigs/web/WebRoutes.kt | 1 + .../twigs/web/transaction/TransactionPages.kt | 19 +- .../web/transaction/TransactionWebRoutes.kt | 205 ++++++++++++++---- web/src/main/resources/static/style.css | 46 +++- .../templates/budget-details.mustache | 76 +++++-- .../templates/category-details.mustache | 14 +- .../templates/partials/budget-list.mustache | 5 + .../templates/partials/sidebar.mustache | 2 +- .../templates/transaction-details.mustache | 9 +- .../templates/transaction-form.mustache | 58 +++-- 12 files changed, 326 insertions(+), 113 deletions(-) diff --git a/app/src/main/kotlin/com/wbrawner/twigs/server/Application.kt b/app/src/main/kotlin/com/wbrawner/twigs/server/Application.kt index f3ecb2b..0218647 100644 --- a/app/src/main/kotlin/com/wbrawner/twigs/server/Application.kt +++ b/app/src/main/kotlin/com/wbrawner/twigs/server/Application.kt @@ -1,7 +1,6 @@ package com.wbrawner.twigs.server import at.favre.lib.crypto.bcrypt.BCrypt -import ch.qos.logback.classic.Level import com.github.mustachejava.DefaultMustacheFactory import com.wbrawner.twigs.* import com.wbrawner.twigs.db.* @@ -38,7 +37,6 @@ import io.ktor.server.response.* import io.ktor.server.sessions.* import kotlinx.coroutines.* import kotlinx.serialization.json.Json -import org.slf4j.LoggerFactory import java.util.concurrent.TimeUnit fun main() { @@ -72,7 +70,6 @@ fun Application.module() { throw RuntimeException("Unsupported DB type: $dbType") } } - (LoggerFactory.getLogger("com.zaxxer.hikari") as ch.qos.logback.classic.Logger).level = Level.DEBUG HikariDataSource(HikariConfig().apply { setJdbcUrl(jdbcUrl) username = dbUser diff --git a/docker-compose.yml b/docker-compose.yml index 3a0bde4..3d6f214 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,7 @@ services: - db environment: TWIGS_DB_HOST: db + TWIGS_DB_TYPE: postgresql networks: - twigs 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 823ea81..805a88d 100644 --- a/web/src/main/kotlin/com/wbrawner/twigs/web/WebRoutes.kt +++ b/web/src/main/kotlin/com/wbrawner/twigs/web/WebRoutes.kt @@ -31,6 +31,7 @@ fun Application.webRoutes( try { userService.session(it.token) } catch (e: HttpException) { + application.environment.log.debug("Failed to retrieve session for user", e) null } } diff --git a/web/src/main/kotlin/com/wbrawner/twigs/web/transaction/TransactionPages.kt b/web/src/main/kotlin/com/wbrawner/twigs/web/transaction/TransactionPages.kt index c8fe491..ca617e8 100644 --- a/web/src/main/kotlin/com/wbrawner/twigs/web/transaction/TransactionPages.kt +++ b/web/src/main/kotlin/com/wbrawner/twigs/web/transaction/TransactionPages.kt @@ -24,8 +24,7 @@ data class TransactionFormPage( val transaction: TransactionResponse, val amountLabel: String, val budget: BudgetResponse, - val incomeCategories: List, - val expenseCategories: List, + val categoryOptions: List, override val user: UserResponse, override val error: String? = null ) : AuthenticatedPage { @@ -34,4 +33,20 @@ data class TransactionFormPage( } else { "Edit Transaction" } + + data class CategoryOption( + val id: String, + val title: String, + val isSelected: Boolean = false, + val isDisabled: Boolean = false + ) { + val selected: String + get() = if (isSelected) "selected" else "" + + val disabled: String + get() = if (isDisabled) "disabled" else "" + } } + +fun CategoryResponse.asOption(selectedCategoryId: String) = + TransactionFormPage.CategoryOption(id, title, id == selectedCategoryId) \ No newline at end of file diff --git a/web/src/main/kotlin/com/wbrawner/twigs/web/transaction/TransactionWebRoutes.kt b/web/src/main/kotlin/com/wbrawner/twigs/web/transaction/TransactionWebRoutes.kt index d0a5f18..7c9ea2d 100644 --- a/web/src/main/kotlin/com/wbrawner/twigs/web/transaction/TransactionWebRoutes.kt +++ b/web/src/main/kotlin/com/wbrawner/twigs/web/transaction/TransactionWebRoutes.kt @@ -7,6 +7,7 @@ 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.UserResponse import com.wbrawner.twigs.service.user.UserService import com.wbrawner.twigs.toInstant import com.wbrawner.twigs.web.NotFoundPage @@ -21,6 +22,7 @@ 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 @@ -36,6 +38,7 @@ private val decimalFormat = DecimalFormat.getNumberInstance(Locale.US).apply { with(this as DecimalFormat) { decimalFormatSymbols = decimalFormatSymbols.apply { currencySymbol = "" + isGroupingUsed = false } } } @@ -59,35 +62,25 @@ fun Application.transactionWebRoutes( get { val user = userService.user(requireSession().userId) val budgetId = call.parameters.getOrFail("budgetId") + val transaction = TransactionResponse( + id = "", + title = "", + description = "", + amount = 0, + budgetId = budgetId, + expense = true, + date = Instant.now().toHtmlInputString(), + categoryId = null, + createdBy = user.id + ) call.respond( MustacheContent( "transaction-form.mustache", TransactionFormPage( - TransactionResponse( - id = "", - title = "", - description = "", - amount = 0, - budgetId = budgetId, - expense = true, - date = Instant.now().toHtmlInputString(), - categoryId = null, - createdBy = user.id - ), + transaction = transaction, amountLabel = currencyFormat.format(0L), 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 - ), + categoryOptions = categoryOptions(transaction, categoryService, budgetId, user), user = user ) ) @@ -101,7 +94,7 @@ fun Application.transactionWebRoutes( val request = call.receiveParameters().toTransactionRequest() .run { copy( - date = date + 'Z', + date = "$date:00Z", expense = categoryService.category( categoryId = requireNotNull(categoryId), userId = user.id @@ -112,35 +105,30 @@ fun Application.transactionWebRoutes( val transaction = transactionService.save(request, user.id) call.respondRedirect("/budgets/${transaction.budgetId}/transactions/${transaction.id}") } catch (e: HttpException) { + val transaction = TransactionResponse( + id = "", + title = call.parameters["title"], + description = call.parameters["description"], + amount = 0L, + budgetId = urlBudgetId, + expense = call.parameters["expense"]?.toBoolean() ?: true, + date = call.parameters["date"].orEmpty(), + categoryId = call.parameters["categoryId"], + createdBy = user.id + ) call.respond( status = e.statusCode, MustacheContent( "transaction-form.mustache", TransactionFormPage( - TransactionResponse( - id = "", - title = call.parameters["title"], - description = call.parameters["description"], - amount = 0L, - budgetId = urlBudgetId, - expense = call.parameters["expense"]?.toBoolean() ?: true, - date = call.parameters["date"].orEmpty(), - categoryId = call.parameters["categoryId"], - createdBy = user.id - ), + transaction = transaction, amountLabel = call.parameters["amount"].orEmpty(), budget = budgetService.budget(budgetId = urlBudgetId, userId = user.id), - incomeCategories = categoryService.categories( - budgetIds = listOf(urlBudgetId), - userId = user.id, - expense = false, - archived = false - ), - expenseCategories = categoryService.categories( - budgetIds = listOf(urlBudgetId), - userId = user.id, - expense = true, - archived = false + categoryOptions = categoryOptions( + transaction, + categoryService, + urlBudgetId, + user ), user = user, error = e.message @@ -197,8 +185,82 @@ fun Application.transactionWebRoutes( } } - post { + route("/edit") { + get { + val user = userService.user(requireSession().userId) + val budgetId = call.parameters.getOrFail("budgetId") + val transaction = transactionService.transaction( + transactionId = call.parameters.getOrFail("id"), + userId = user.id + ) + call.respond( + MustacheContent( + "transaction-form.mustache", + TransactionFormPage( + transaction = transaction.copy( + date = transaction.date.toInstant().toHtmlInputString() + ), + amountLabel = transaction.amount.toDecimalString(), + budget = budgetService.budget(budgetId = budgetId, userId = user.id), + categoryOptions = categoryOptions(transaction, categoryService, budgetId, user), + user = user + ) + ) + ) + } + post { + val user = userService.user(requireSession().userId) + val transactionId = call.parameters.getOrFail("id") + val urlBudgetId = call.parameters.getOrFail("budgetId") + try { + val request = call.receiveParameters().toTransactionRequest() + .run { + copy( + date = "$date:00Z", + expense = categoryService.category( + categoryId = requireNotNull(categoryId), + userId = user.id + ).expense, + budgetId = urlBudgetId + ) + } + val transaction = + transactionService.save(request, userId = user.id, transactionId = transactionId) + call.respondRedirect("/budgets/${transaction.budgetId}/transactions/${transaction.id}") + } catch (e: HttpException) { + val transaction = TransactionResponse( + id = transactionId, + title = call.parameters["title"], + description = call.parameters["description"], + amount = 0L, + budgetId = urlBudgetId, + expense = call.parameters["expense"]?.toBoolean() ?: true, + date = call.parameters["date"].orEmpty(), + categoryId = call.parameters["categoryId"], + createdBy = user.id + ) + call.respond( + status = e.statusCode, + MustacheContent( + "transaction-form.mustache", + TransactionFormPage( + transaction = transaction, + amountLabel = call.parameters["amount"].orEmpty(), + budget = budgetService.budget(budgetId = urlBudgetId, userId = user.id), + categoryOptions = categoryOptions( + transaction, + categoryService, + urlBudgetId, + user + ), + user = user, + error = e.message + ) + ) + ) + } + } } } } @@ -206,6 +268,48 @@ fun Application.transactionWebRoutes( } } +private suspend fun categoryOptions( + transaction: TransactionResponse, + categoryService: CategoryService, + budgetId: String, + user: UserResponse +): List { + val selectedCategoryId = transaction.categoryId.orEmpty() + val categoryOptions = listOf( + TransactionFormPage.CategoryOption( + "", + "Select a category", + isSelected = transaction.categoryId.isNullOrBlank(), + isDisabled = true + ), + TransactionFormPage.CategoryOption("income", "Income", isDisabled = true), + ) + .plus( + categoryService.categories( + budgetIds = listOf(budgetId), + userId = user.id, + expense = false, + archived = false + ).map { category -> + category.asOption(selectedCategoryId) + } + ) + .plus( + TransactionFormPage.CategoryOption("expense", "Expense", isDisabled = true), + ) + .plus( + categoryService.categories( + budgetIds = listOf(budgetId), + userId = user.id, + expense = true, + archived = false + ).map { category -> + category.asOption(selectedCategoryId) + } + ) + return categoryOptions +} + private fun Parameters.toTransactionRequest() = TransactionRequest( title = get("title"), description = get("description"), @@ -216,4 +320,9 @@ private fun Parameters.toTransactionRequest() = TransactionRequest( budgetId = get("budgetId"), ) -private fun Instant.toHtmlInputString() = truncatedTo(ChronoUnit.SECONDS).toString().substringBefore('Z') \ No newline at end of file +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)) +} \ 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 546aa9b..f6dd800 100644 --- a/web/src/main/resources/static/style.css +++ b/web/src/main/resources/static/style.css @@ -71,6 +71,11 @@ main { max-width: 300px; } +#hamburger { + color: var(--color-on-background); + text-decoration: none; +} + .stacked-label { display: flex; flex-direction: column; @@ -100,9 +105,19 @@ main { justify-content: space-evenly; } +header.row { + justify-content: space-between; + align-items: center; +} + +.card header .button { + margin: 0.5rem; +} + .card { background: var(--background-color-primary); border-radius: var(--border-radius); + margin-bottom: 1rem; } .button { @@ -146,12 +161,15 @@ main { width: 200px; } +.center form { + width: 100%; + max-width: 1200px; +} + form { display: flex; flex-direction: column; justify-content: space-between; - width: 100%; - max-width: 1200px; } input, select, textarea { @@ -201,6 +219,30 @@ a { font-size: 0.9rem; } +@media all and (max-width: 600px) { + .hide-small { + display: none; + } +} + +@media all and (max-width: 900px) { + #sidebar { + background-color: var(--background-color-primary); + position: fixed; + transform: translateX(-100%); + } + + #sidebar:target { + transform: translateX(0); + } +} + +@media all and (min-width: 900px) { + #hamburger { + display: none; + } +} + @media all and (max-width: 400px) { button { width: 100%; diff --git a/web/src/main/resources/templates/budget-details.mustache b/web/src/main/resources/templates/budget-details.mustache index f0d6b77..d86c38a 100644 --- a/web/src/main/resources/templates/budget-details.mustache +++ b/web/src/main/resources/templates/budget-details.mustache @@ -2,30 +2,60 @@
{{>partials/sidebar}}
-

{{budget.name}}

+
+ +

{{title}}

+ + + + + +

{{budget.description}}

-
-
-

Month

-

{{monthAndYear}}

-
-
-

Cash Flow

-

{{balances.cashFlow}}

-
-
-

Transactions

-

{{transactionCount}}

+
+
+
+

Month

+

{{monthAndYear}}

+
+
+

Cash Flow

+

{{balances.cashFlow}}

+
+
+

Transactions

+

{{transactionCount}}

+
-

Income

-

{{balances.actualIncomeLabel}} earned of {{balances.expectedIncomeLabel}} expected

+

Expected income: {{balances.expectedIncomeLabel}}

+ {{balances.expectedIncomeLabel}} +

Actual Income: {{balances.actualIncomeLabel}}

{{balances.actualIncomeLabel}} +

Expected expenses: {{balances.expectedExpensesLabel}}

+ {{balances.expectedExpensesLabel}} +

Actual Income: {{balances.actualIncomeLabel}}

+ {{balances.actualExpensesLabel}}
+
+
+
+

Income

+ + + + + +
    {{#incomeCategories}} {{>partials/category-list}} @@ -39,13 +69,15 @@ {{/archivedIncomeCategories}}
-

Expenses

-
-

{{balances.actualExpensesLabel}} spent of {{balances.expectedExpensesLabel}} expected

- {{balances.actualExpensesLabel}} -
+
+
+
+

Expenses

+ + + + + +
    {{#expenseCategories}} {{>partials/category-list}} diff --git a/web/src/main/resources/templates/category-details.mustache b/web/src/main/resources/templates/category-details.mustache index 1766199..a382870 100644 --- a/web/src/main/resources/templates/category-details.mustache +++ b/web/src/main/resources/templates/category-details.mustache @@ -3,8 +3,18 @@ {{>partials/sidebar}}
    -

    {{category.category.title}}

    +
    + +

    {{title}}

    + + + + + +

    {{category.category.description}}

    +
    +

    Budgeted

    @@ -22,8 +32,8 @@ {{category.balanceLabel}}
    -
    +

    Transactions

      {{#transactions}} diff --git a/web/src/main/resources/templates/partials/budget-list.mustache b/web/src/main/resources/templates/partials/budget-list.mustache index 0a7217c..a93b977 100644 --- a/web/src/main/resources/templates/partials/budget-list.mustache +++ b/web/src/main/resources/templates/partials/budget-list.mustache @@ -7,6 +7,11 @@ {{/budgets}} +
    • + + + New Budget + +
    {{^budgets}}

    Welcome to Twigs! It looks like you haven't created any budgets yet. Budgets are the diff --git a/web/src/main/resources/templates/partials/sidebar.mustache b/web/src/main/resources/templates/partials/sidebar.mustache index d716ef7..0d1076a 100644 --- a/web/src/main/resources/templates/partials/sidebar.mustache +++ b/web/src/main/resources/templates/partials/sidebar.mustache @@ -1,4 +1,4 @@ -