Compare commits
3 commits
Author | SHA1 | Date | |
---|---|---|---|
0fd0b16b4c | |||
a3d9be660e | |||
e21d2fbd32 |
15 changed files with 580 additions and 76 deletions
|
@ -260,7 +260,7 @@ fun Application.moduleWithDependencies(
|
|||
recurringTransactionRoutes(recurringTransactionService)
|
||||
transactionRoutes(transactionService)
|
||||
userRoutes(userService)
|
||||
webRoutes(budgetService, categoryService, transactionService, userService)
|
||||
webRoutes(budgetService, categoryService, recurringTransactionService, transactionService, userService)
|
||||
launch {
|
||||
while (currentCoroutineContext().isActive) {
|
||||
jobs.forEach { it.run() }
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
version: "3.3"
|
||||
|
||||
services:
|
||||
twigs:
|
||||
build: .
|
||||
|
|
|
@ -25,6 +25,7 @@ data class RecurringTransactionResponse(
|
|||
val frequency: String,
|
||||
val start: String,
|
||||
val finish: String?,
|
||||
val lastRun: String?,
|
||||
val amount: Long?,
|
||||
val expense: Boolean?,
|
||||
val budgetId: String,
|
||||
|
@ -39,6 +40,7 @@ fun RecurringTransaction.asResponse(): RecurringTransactionResponse = RecurringT
|
|||
frequency = frequency.toString(),
|
||||
start = start.truncatedTo(ChronoUnit.SECONDS).toString(),
|
||||
finish = finish?.truncatedTo(ChronoUnit.SECONDS)?.toString(),
|
||||
lastRun = lastRun?.truncatedTo(ChronoUnit.SECONDS)?.toString(),
|
||||
amount = amount,
|
||||
expense = expense,
|
||||
budgetId = budgetId,
|
||||
|
|
|
@ -4,10 +4,12 @@ import com.wbrawner.twigs.model.CookieSession
|
|||
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.recurringtransaction.RecurringTransactionService
|
||||
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.recurring.recurringTransactionWebRoutes
|
||||
import com.wbrawner.twigs.web.transaction.transactionWebRoutes
|
||||
import com.wbrawner.twigs.web.user.userWebRoutes
|
||||
import io.ktor.server.application.*
|
||||
|
@ -20,6 +22,7 @@ import io.ktor.server.sessions.*
|
|||
fun Application.webRoutes(
|
||||
budgetService: BudgetService,
|
||||
categoryService: CategoryService,
|
||||
recurringTransactionService: RecurringTransactionService,
|
||||
transactionService: TransactionService,
|
||||
userService: UserService
|
||||
) {
|
||||
|
@ -47,6 +50,13 @@ fun Application.webRoutes(
|
|||
}
|
||||
budgetWebRoutes(budgetService, categoryService, transactionService, userService)
|
||||
categoryWebRoutes(budgetService, categoryService, transactionService, userService)
|
||||
recurringTransactionWebRoutes(
|
||||
budgetService,
|
||||
categoryService,
|
||||
recurringTransactionService,
|
||||
transactionService,
|
||||
userService
|
||||
)
|
||||
transactionWebRoutes(budgetService, categoryService, transactionService, userService)
|
||||
userWebRoutes(userService)
|
||||
}
|
|
@ -1,15 +1,29 @@
|
|||
package com.wbrawner.twigs.web
|
||||
|
||||
import com.wbrawner.twigs.endOfMonth
|
||||
import com.wbrawner.twigs.firstOfMonth
|
||||
import com.wbrawner.twigs.model.Frequency
|
||||
import com.wbrawner.twigs.service.recurringtransaction.RecurringTransactionResponse
|
||||
import com.wbrawner.twigs.service.transaction.TransactionResponse
|
||||
import com.wbrawner.twigs.toInstant
|
||||
import com.wbrawner.twigs.toInstantOrNull
|
||||
import com.wbrawner.twigs.web.recurring.toListItem
|
||||
import com.wbrawner.twigs.web.transaction.toListItem
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.util.*
|
||||
import io.ktor.util.date.*
|
||||
import java.math.BigDecimal
|
||||
import java.math.RoundingMode
|
||||
import java.text.DateFormat
|
||||
import java.text.DecimalFormat
|
||||
import java.text.NumberFormat
|
||||
import java.time.Instant
|
||||
import java.time.ZonedDateTime
|
||||
import java.time.temporal.ChronoUnit
|
||||
import java.util.*
|
||||
|
||||
val currencyFormat = NumberFormat.getCurrencyInstance(Locale.US)
|
||||
val decimalFormat = DecimalFormat.getNumberInstance(Locale.US).apply {
|
||||
val currencyFormat: NumberFormat = NumberFormat.getCurrencyInstance(Locale.US)
|
||||
val decimalFormat: NumberFormat = DecimalFormat.getNumberInstance(Locale.US).apply {
|
||||
with(this as DecimalFormat) {
|
||||
decimalFormatSymbols = decimalFormatSymbols.apply {
|
||||
currencySymbol = ""
|
||||
|
@ -17,8 +31,7 @@ val decimalFormat = DecimalFormat.getNumberInstance(Locale.US).apply {
|
|||
}
|
||||
}
|
||||
}
|
||||
val shortDateFormat = DateFormat.getDateInstance(DateFormat.SHORT, Locale.US)
|
||||
|
||||
val shortDateFormat: DateFormat = DateFormat.getDateInstance(DateFormat.SHORT, Locale.US)
|
||||
|
||||
fun Parameters.getAmount() = decimalFormat.parse(get("amount"))
|
||||
?.toDouble()
|
||||
|
@ -31,3 +44,76 @@ fun Long?.toDecimalString(): String {
|
|||
if (this == null) return ""
|
||||
return decimalFormat.format(toBigDecimal().divide(BigDecimal(100), 2, RoundingMode.HALF_UP))
|
||||
}
|
||||
|
||||
fun Instant.toHtmlInputString() = truncatedTo(ChronoUnit.MINUTES).toString().substringBefore(":00Z")
|
||||
|
||||
data class ListGroup<T>(val label: String, val items: List<T>)
|
||||
|
||||
fun List<TransactionResponse>.groupByDate() =
|
||||
groupBy {
|
||||
it.date.toInstant().truncatedTo(ChronoUnit.DAYS)
|
||||
}
|
||||
.entries
|
||||
.sortedByDescending { it.key }
|
||||
.map { (date, transactions) ->
|
||||
ListGroup(
|
||||
shortDateFormat.format(date.toGMTDate().toJvmDate()),
|
||||
transactions.map { it.toListItem(currencyFormat) })
|
||||
}
|
||||
|
||||
|
||||
val RecurringTransactionResponse.isThisMonth: Boolean
|
||||
get() {
|
||||
if (isExpired) {
|
||||
return false
|
||||
}
|
||||
// TODO: Check user's timezone for this
|
||||
return when (val frequencyObj = Frequency.parse(frequency)) {
|
||||
is Frequency.Daily -> true
|
||||
is Frequency.Weekly -> frequencyObj.count < 5
|
||||
|| (lastRun ?: start).toInstant()
|
||||
.plus(frequencyObj.count.toLong(), ChronoUnit.WEEKS)
|
||||
.run {
|
||||
isAfter(firstOfMonth) && isBefore(endOfMonth)
|
||||
}
|
||||
|
||||
is Frequency.Monthly -> frequencyObj.count < 2
|
||||
|| (lastRun ?: start).toInstant()
|
||||
.plus(frequencyObj.count.toLong(), ChronoUnit.MONTHS)
|
||||
.run {
|
||||
isAfter(firstOfMonth) && isBefore(endOfMonth)
|
||||
}
|
||||
|
||||
is Frequency.Yearly -> ZonedDateTime.now().month == frequencyObj.dayOfYear.month
|
||||
}
|
||||
}
|
||||
|
||||
val RecurringTransactionResponse.isExpired: Boolean
|
||||
get() = finish?.toInstantOrNull()?.let { firstOfMonth > it } == true
|
||||
|
||||
fun List<RecurringTransactionResponse>.groupByOccurrence() =
|
||||
groupBy {
|
||||
when {
|
||||
it.isThisMonth -> RecurringTransactionOccurrence.THIS_MONTH
|
||||
!it.isExpired -> RecurringTransactionOccurrence.FUTURE
|
||||
else -> RecurringTransactionOccurrence.EXPIRED
|
||||
}
|
||||
}
|
||||
.entries
|
||||
.sortedBy { (k, _) -> k.ordinal }
|
||||
.map { (occurrence, transactions) ->
|
||||
// TODO: I18n
|
||||
ListGroup(occurrence.niceName, transactions.sortedBy { it.title }.map { it.toListItem(currencyFormat) })
|
||||
}
|
||||
|
||||
enum class RecurringTransactionOccurrence {
|
||||
THIS_MONTH,
|
||||
FUTURE,
|
||||
EXPIRED;
|
||||
|
||||
val niceName = name.split("_")
|
||||
.joinToString(" ") { word ->
|
||||
word.lowercase()
|
||||
.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() }
|
||||
}
|
||||
}
|
|
@ -2,18 +2,17 @@ package com.wbrawner.twigs.web.category
|
|||
|
||||
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
|
||||
import com.wbrawner.twigs.web.BudgetListItem
|
||||
import com.wbrawner.twigs.web.budget.toCurrencyString
|
||||
import java.text.NumberFormat
|
||||
import com.wbrawner.twigs.web.ListGroup
|
||||
import com.wbrawner.twigs.web.transaction.TransactionListItem
|
||||
|
||||
data class CategoryDetailsPage(
|
||||
val category: CategoryWithBalanceResponse,
|
||||
val budget: BudgetResponse,
|
||||
val transactionCount: String,
|
||||
val transactions: List<Map.Entry<String, List<TransactionListItem>>>,
|
||||
val transactions: List<ListGroup<TransactionListItem>>,
|
||||
override val budgets: List<BudgetListItem>,
|
||||
override val user: UserResponse,
|
||||
override val error: String? = null
|
||||
|
@ -21,24 +20,6 @@ data class CategoryDetailsPage(
|
|||
override val title: String = category.category.title
|
||||
}
|
||||
|
||||
data class TransactionListItem(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val budgetId: String,
|
||||
val expenseClass: String,
|
||||
val amountLabel: String
|
||||
)
|
||||
|
||||
fun TransactionResponse.toListItem(numberFormat: NumberFormat) = TransactionListItem(
|
||||
id,
|
||||
title.orEmpty(),
|
||||
description.orEmpty(),
|
||||
budgetId,
|
||||
if (expense != false) "expense" else "income",
|
||||
(amount ?: 0L).toCurrencyString(numberFormat)
|
||||
)
|
||||
|
||||
data class CategoryFormPage(
|
||||
val category: CategoryResponse,
|
||||
val amountLabel: String,
|
||||
|
@ -65,3 +46,18 @@ data class CategoryWithBalanceResponse(
|
|||
val balanceLabel: String,
|
||||
val remainingAmountLabel: String,
|
||||
)
|
||||
|
||||
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) = CategoryOption(id, title, id == selectedCategoryId)
|
|
@ -10,7 +10,6 @@ import com.wbrawner.twigs.service.category.CategoryService
|
|||
import com.wbrawner.twigs.service.requireSession
|
||||
import com.wbrawner.twigs.service.transaction.TransactionService
|
||||
import com.wbrawner.twigs.service.user.UserService
|
||||
import com.wbrawner.twigs.toInstant
|
||||
import com.wbrawner.twigs.toInstantOrNull
|
||||
import com.wbrawner.twigs.web.*
|
||||
import com.wbrawner.twigs.web.budget.toCurrencyString
|
||||
|
@ -23,7 +22,6 @@ import io.ktor.server.request.*
|
|||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import io.ktor.server.util.*
|
||||
import io.ktor.util.date.*
|
||||
import java.text.NumberFormat
|
||||
import java.util.*
|
||||
import kotlin.math.abs
|
||||
|
@ -132,12 +130,6 @@ fun Application.categoryWebRoutes(
|
|||
)
|
||||
val transactionCount = NumberFormat.getNumberInstance(Locale.US)
|
||||
.format(transactions.size)
|
||||
val transactionsByDate = transactions.groupBy {
|
||||
shortDateFormat.format(it.date.toInstant().toGMTDate().toJvmDate())
|
||||
}
|
||||
.mapValues { (_, transactions) -> transactions.map { it.toListItem(currencyFormat) } }
|
||||
.entries
|
||||
.sortedByDescending { it.key }
|
||||
val budgets = budgetService.budgetsForUser(user.id)
|
||||
val budgetId = call.parameters.getOrFail("budgetId")
|
||||
val budget = budgets.first { it.id == budgetId }
|
||||
|
@ -145,7 +137,7 @@ fun Application.categoryWebRoutes(
|
|||
MustacheContent(
|
||||
"category-details.mustache", CategoryDetailsPage(
|
||||
category = categoryWithBalance,
|
||||
transactions = transactionsByDate,
|
||||
transactions = transactions.groupByDate(),
|
||||
transactionCount = transactionCount,
|
||||
budgets = budgets.map { it.toBudgetListItem(budgetId) },
|
||||
budget = budget,
|
||||
|
|
|
@ -0,0 +1,62 @@
|
|||
package com.wbrawner.twigs.web.recurring
|
||||
|
||||
import com.wbrawner.twigs.service.budget.BudgetResponse
|
||||
import com.wbrawner.twigs.service.category.CategoryResponse
|
||||
import com.wbrawner.twigs.service.recurringtransaction.RecurringTransactionResponse
|
||||
import com.wbrawner.twigs.service.user.UserResponse
|
||||
import com.wbrawner.twigs.web.AuthenticatedPage
|
||||
import com.wbrawner.twigs.web.BudgetListItem
|
||||
import com.wbrawner.twigs.web.ListGroup
|
||||
import com.wbrawner.twigs.web.budget.toCurrencyString
|
||||
import com.wbrawner.twigs.web.category.CategoryOption
|
||||
import com.wbrawner.twigs.web.transaction.TransactionListItem
|
||||
import java.text.NumberFormat
|
||||
|
||||
data class RecurringTransactionListPage(
|
||||
val budget: BudgetResponse,
|
||||
val transactions: List<ListGroup<TransactionListItem>>,
|
||||
override val budgets: List<BudgetListItem>,
|
||||
override val user: UserResponse,
|
||||
override val error: String? = null
|
||||
) : AuthenticatedPage {
|
||||
override val title: String = "Recurring Transactions"
|
||||
}
|
||||
|
||||
fun RecurringTransactionResponse.toListItem(numberFormat: NumberFormat) = TransactionListItem(
|
||||
id = id,
|
||||
title = title.orEmpty(),
|
||||
description = description.orEmpty(),
|
||||
budgetId = budgetId,
|
||||
expenseClass = if (expense != false) "expense" else "income",
|
||||
amountLabel = (amount ?: 0L).toCurrencyString(numberFormat)
|
||||
)
|
||||
|
||||
data class RecurringTransactionDetailsPage(
|
||||
val transaction: RecurringTransactionResponse,
|
||||
val category: CategoryResponse?,
|
||||
val budget: BudgetResponse,
|
||||
val amountLabel: String,
|
||||
val dateLabel: String,
|
||||
val createdBy: UserResponse,
|
||||
override val budgets: List<BudgetListItem>,
|
||||
override val user: UserResponse,
|
||||
override val error: String? = null
|
||||
) : AuthenticatedPage {
|
||||
override val title: String = transaction.title.orEmpty()
|
||||
}
|
||||
|
||||
data class RecurringTransactionFormPage(
|
||||
val transaction: RecurringTransactionResponse,
|
||||
val amountLabel: String,
|
||||
val budget: BudgetResponse,
|
||||
val categoryOptions: List<CategoryOption>,
|
||||
override val budgets: List<BudgetListItem>,
|
||||
override val user: UserResponse,
|
||||
override val error: String? = null
|
||||
) : AuthenticatedPage {
|
||||
override val title: String = if (transaction.id.isBlank()) {
|
||||
"New Recurring Transaction"
|
||||
} else {
|
||||
"Edit Recurring Transaction"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,300 @@
|
|||
package com.wbrawner.twigs.web.recurring
|
||||
|
||||
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.recurringtransaction.RecurringTransactionService
|
||||
import com.wbrawner.twigs.service.requireSession
|
||||
import com.wbrawner.twigs.service.transaction.TransactionRequest
|
||||
import com.wbrawner.twigs.service.transaction.TransactionService
|
||||
import com.wbrawner.twigs.service.user.UserService
|
||||
import com.wbrawner.twigs.toInstant
|
||||
import com.wbrawner.twigs.web.*
|
||||
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.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import io.ktor.server.util.*
|
||||
import java.time.ZoneOffset.UTC
|
||||
|
||||
fun Application.recurringTransactionWebRoutes(
|
||||
budgetService: BudgetService,
|
||||
categoryService: CategoryService,
|
||||
recurringTransactionService: RecurringTransactionService,
|
||||
transactionService: TransactionService,
|
||||
userService: UserService
|
||||
) {
|
||||
routing {
|
||||
authenticate(TWIGS_SESSION_COOKIE) {
|
||||
route("/budgets/{budgetId}/recurring") {
|
||||
get {
|
||||
val user = userService.user(requireSession().userId)
|
||||
val budgetId = call.parameters.getOrFail("budgetId")
|
||||
val budgets = budgetService.budgetsForUser(user.id)
|
||||
val transactions = recurringTransactionService.recurringTransactions(
|
||||
budgetId = budgetId,
|
||||
userId = user.id
|
||||
)
|
||||
call.respond(
|
||||
MustacheContent(
|
||||
"recurring-transactions.mustache",
|
||||
RecurringTransactionListPage(
|
||||
budgets = budgets.map { it.toBudgetListItem(budgetId) },
|
||||
budget = budgets.first { it.id == budgetId },
|
||||
transactions = transactions.groupByOccurrence(),
|
||||
user = user
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
route("/new") {
|
||||
// get {
|
||||
// val user = userService.user(requireSession().userId)
|
||||
// val budgetId = call.parameters.getOrFail("budgetId")
|
||||
// val budgets = budgetService.budgetsForUser(user.id)
|
||||
// val budget = budgets.first { it.id == budgetId }
|
||||
// val categoryId = call.request.queryParameters["categoryId"]
|
||||
// val transaction = TransactionResponse(
|
||||
// id = "",
|
||||
// title = "",
|
||||
// description = "",
|
||||
// amount = 0,
|
||||
// budgetId = budgetId,
|
||||
// expense = true,
|
||||
// date = Instant.now().toHtmlInputString(),
|
||||
// categoryId = categoryId,
|
||||
// createdBy = user.id
|
||||
// )
|
||||
// call.respond(
|
||||
// MustacheContent(
|
||||
// "transaction-form.mustache",
|
||||
// TransactionFormPage(
|
||||
// transaction = transaction,
|
||||
// amountLabel = 0L.toDecimalString(),
|
||||
// budget = budget,
|
||||
// categoryOptions = categoryOptions(
|
||||
// transaction = transaction,
|
||||
// categoryService = categoryService,
|
||||
// budgetId = budgetId,
|
||||
// user = user
|
||||
// ),
|
||||
// budgets = budgets.map { it.toBudgetListItem(budgetId) },
|
||||
// user = user
|
||||
// )
|
||||
// )
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// post {
|
||||
// val user = userService.user(requireSession().userId)
|
||||
// val urlBudgetId = call.parameters.getOrFail("budgetId")
|
||||
// val budgets = budgetService.budgetsForUser(user.id)
|
||||
// val budget = budgets.first { it.id == urlBudgetId }
|
||||
// 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, 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(
|
||||
// transaction = transaction,
|
||||
// amountLabel = call.parameters["amount"].orEmpty(),
|
||||
// budget = budget,
|
||||
// categoryOptions = categoryOptions(
|
||||
// transaction,
|
||||
// categoryService,
|
||||
// urlBudgetId,
|
||||
// user
|
||||
// ),
|
||||
// budgets = budgets.map { it.toBudgetListItem(urlBudgetId) },
|
||||
// user = user,
|
||||
// error = e.message
|
||||
// )
|
||||
// )
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
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 = recurringTransactionService.recurringTransaction(
|
||||
recurringTransactionId = 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 transactionInstant = transaction.start.toInstant()
|
||||
val transactionOffset = transactionInstant.atOffset(UTC)
|
||||
val startLabel = shortDateFormat.format(transactionOffset)
|
||||
call.respond(
|
||||
MustacheContent(
|
||||
"recurring-transaction-details.mustache", RecurringTransactionDetailsPage(
|
||||
transaction = transaction,
|
||||
category = category,
|
||||
budget = budget,
|
||||
budgets = budgets.map { it.toBudgetListItem(budgetId) },
|
||||
amountLabel = transaction.amount?.toCurrencyString(currencyFormat).orEmpty(),
|
||||
dateLabel = startLabel,
|
||||
createdBy = userService.user(transaction.createdBy),
|
||||
user = user
|
||||
)
|
||||
)
|
||||
)
|
||||
} catch (e: HttpException) {
|
||||
call.respond(
|
||||
status = e.statusCode,
|
||||
MustacheContent("404.mustache", NotFoundPage)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// route("/edit") {
|
||||
// get {
|
||||
// val user = userService.user(requireSession().userId)
|
||||
// val budgetId = call.parameters.getOrFail("budgetId")
|
||||
// val budgets = budgetService.budgetsForUser(user.id)
|
||||
// val budget = budgets.first { it.id == 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 = budget,
|
||||
// categoryOptions = categoryOptions(transaction, categoryService, budgetId, user),
|
||||
// budgets = budgets.map { it.toBudgetListItem(budgetId) },
|
||||
// user = user
|
||||
// )
|
||||
// )
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// post {
|
||||
// val user = userService.user(requireSession().userId)
|
||||
// val transactionId = call.parameters.getOrFail("id")
|
||||
// val urlBudgetId = call.parameters.getOrFail("budgetId")
|
||||
// val budgets = budgetService.budgetsForUser(user.id)
|
||||
// val budget = budgets.first { it.id == urlBudgetId }
|
||||
// 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 = budget,
|
||||
// categoryOptions = categoryOptions(
|
||||
// transaction,
|
||||
// categoryService,
|
||||
// urlBudgetId,
|
||||
// user
|
||||
// ),
|
||||
// budgets = budgets.map { it.toBudgetListItem(urlBudgetId) },
|
||||
// user = user,
|
||||
// error = e.message
|
||||
// )
|
||||
// )
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
route("/delete") {
|
||||
post {
|
||||
val user = userService.user(requireSession().userId)
|
||||
val transactionId = call.parameters.getOrFail("id")
|
||||
val urlBudgetId = call.parameters.getOrFail("budgetId")
|
||||
transactionService.delete(transactionId = transactionId, userId = user.id)
|
||||
call.respondRedirect("/budgets/${urlBudgetId}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun Parameters.toRecurringTransactionRequest() = TransactionRequest(
|
||||
title = get("title"),
|
||||
description = get("description"),
|
||||
amount = getAmount(),
|
||||
expense = false,
|
||||
date = get("date"),
|
||||
categoryId = get("categoryId"),
|
||||
budgetId = get("budgetId"),
|
||||
)
|
|
@ -6,11 +6,14 @@ import com.wbrawner.twigs.service.transaction.TransactionResponse
|
|||
import com.wbrawner.twigs.service.user.UserResponse
|
||||
import com.wbrawner.twigs.web.AuthenticatedPage
|
||||
import com.wbrawner.twigs.web.BudgetListItem
|
||||
import com.wbrawner.twigs.web.category.TransactionListItem
|
||||
import com.wbrawner.twigs.web.ListGroup
|
||||
import com.wbrawner.twigs.web.budget.toCurrencyString
|
||||
import com.wbrawner.twigs.web.category.CategoryOption
|
||||
import java.text.NumberFormat
|
||||
|
||||
data class TransactionListPage(
|
||||
val budget: BudgetResponse,
|
||||
val transactions: List<Map.Entry<String, List<TransactionListItem>>>,
|
||||
val transactions: List<ListGroup<TransactionListItem>>,
|
||||
override val budgets: List<BudgetListItem>,
|
||||
override val user: UserResponse,
|
||||
override val error: String? = null
|
||||
|
@ -47,20 +50,22 @@ 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)
|
||||
data class TransactionListItem(
|
||||
val id: String,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val budgetId: String,
|
||||
val expenseClass: String,
|
||||
val amountLabel: String
|
||||
)
|
||||
|
||||
fun TransactionResponse.toListItem(numberFormat: NumberFormat) = TransactionListItem(
|
||||
id,
|
||||
title.orEmpty(),
|
||||
description.orEmpty(),
|
||||
budgetId,
|
||||
if (expense != false) "expense" else "income",
|
||||
(amount ?: 0L).toCurrencyString(numberFormat)
|
||||
)
|
||||
|
|
|
@ -15,7 +15,8 @@ import com.wbrawner.twigs.toInstant
|
|||
import com.wbrawner.twigs.toInstantOrNull
|
||||
import com.wbrawner.twigs.web.*
|
||||
import com.wbrawner.twigs.web.budget.toCurrencyString
|
||||
import com.wbrawner.twigs.web.category.toListItem
|
||||
import com.wbrawner.twigs.web.category.CategoryOption
|
||||
import com.wbrawner.twigs.web.category.asOption
|
||||
import com.wbrawner.twigs.web.user.TWIGS_SESSION_COOKIE
|
||||
import io.ktor.http.*
|
||||
import io.ktor.server.application.*
|
||||
|
@ -25,11 +26,9 @@ import io.ktor.server.request.*
|
|||
import io.ktor.server.response.*
|
||||
import io.ktor.server.routing.*
|
||||
import io.ktor.server.util.*
|
||||
import io.ktor.util.date.*
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset.UTC
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.temporal.ChronoUnit
|
||||
|
||||
fun Application.transactionWebRoutes(
|
||||
budgetService: BudgetService,
|
||||
|
@ -50,19 +49,13 @@ fun Application.transactionWebRoutes(
|
|||
to = call.parameters["to"]?.toInstantOrNull() ?: endOfMonth,
|
||||
userId = user.id
|
||||
)
|
||||
val transactionsByDate = transactions.groupBy {
|
||||
shortDateFormat.format(it.date.toInstant().toGMTDate().toJvmDate())
|
||||
}
|
||||
.mapValues { (_, transactions) -> transactions.map { it.toListItem(currencyFormat) } }
|
||||
.entries
|
||||
.sortedByDescending { it.key }
|
||||
call.respond(
|
||||
MustacheContent(
|
||||
"budget-transactions.mustache",
|
||||
TransactionListPage(
|
||||
budgets = budgets.map { it.toBudgetListItem(budgetId) },
|
||||
budget = budgets.first { it.id == budgetId },
|
||||
transactions = transactionsByDate,
|
||||
transactions = transactions.groupByDate(),
|
||||
user = user
|
||||
)
|
||||
)
|
||||
|
@ -312,16 +305,16 @@ private suspend fun categoryOptions(
|
|||
categoryService: CategoryService,
|
||||
budgetId: String,
|
||||
user: UserResponse
|
||||
): List<TransactionFormPage.CategoryOption> {
|
||||
): List<CategoryOption> {
|
||||
val selectedCategoryId = transaction.categoryId.orEmpty()
|
||||
val categoryOptions = listOf(
|
||||
TransactionFormPage.CategoryOption(
|
||||
CategoryOption(
|
||||
"",
|
||||
"Select a category",
|
||||
isSelected = transaction.categoryId.isNullOrBlank(),
|
||||
isDisabled = true
|
||||
),
|
||||
TransactionFormPage.CategoryOption("income", "Income", isDisabled = true),
|
||||
CategoryOption("income", "Income", isDisabled = true),
|
||||
)
|
||||
.plus(
|
||||
categoryService.categories(
|
||||
|
@ -334,7 +327,7 @@ private suspend fun categoryOptions(
|
|||
}
|
||||
)
|
||||
.plus(
|
||||
TransactionFormPage.CategoryOption("expense", "Expense", isDisabled = true),
|
||||
CategoryOption("expense", "Expense", isDisabled = true),
|
||||
)
|
||||
.plus(
|
||||
categoryService.categories(
|
||||
|
@ -358,5 +351,3 @@ private fun Parameters.toTransactionRequest() = TransactionRequest(
|
|||
categoryId = get("categoryId"),
|
||||
budgetId = get("budgetId"),
|
||||
)
|
||||
|
||||
private fun Instant.toHtmlInputString() = truncatedTo(ChronoUnit.MINUTES).toString().substringBefore(":00Z")
|
||||
|
|
|
@ -6,3 +6,18 @@ for (let i = 0; i < forms.length; i++) {
|
|||
form.querySelector('input[type="submit"]').disabled = true
|
||||
}
|
||||
}
|
||||
|
||||
const sidebar = document.querySelector('#sidebar')
|
||||
|
||||
document.querySelector('#hamburger').onclick = (e) => {
|
||||
e.preventDefault()
|
||||
sidebar.style.transform = 'translateX(0)'
|
||||
}
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
const style = window.getComputedStyle(sidebar)
|
||||
const matrix = new DOMMatrixReadOnly(style.getPropertyValue("transform"))
|
||||
if (matrix.m41 === 0) {
|
||||
sidebar.style.transform = 'translateX(-100%)'
|
||||
}
|
||||
})
|
|
@ -0,0 +1,18 @@
|
|||
<li class="list-item">
|
||||
<h5>
|
||||
{{label}}
|
||||
</h5>
|
||||
</li>
|
||||
{{#items}}
|
||||
<li class="list-item">
|
||||
<a href="/budgets/{{budgetId}}/recurring/{{id}}">
|
||||
<div class="row" style="justify-content: space-between">
|
||||
<div class="column">
|
||||
<span class="body-large">{{title}}</span>
|
||||
<span class="body-small">{{description}}</span>
|
||||
</div>
|
||||
<span class="body-medium {{expenseClass}}">{{amountLabel}}</span>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{{/items}}
|
|
@ -1,9 +1,9 @@
|
|||
<li class="list-item">
|
||||
<h5>
|
||||
{{key}}
|
||||
{{label}}
|
||||
</h5>
|
||||
</li>
|
||||
{{#value}}
|
||||
{{#items}}
|
||||
<li class="list-item">
|
||||
<a href="/budgets/{{budgetId}}/transactions/{{id}}">
|
||||
<div class="row" style="justify-content: space-between">
|
||||
|
@ -15,4 +15,4 @@
|
|||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{{/value}}
|
||||
{{/items}}
|
|
@ -0,0 +1,29 @@
|
|||
{{> partials/head }}
|
||||
<div id="app">
|
||||
{{>partials/sidebar}}
|
||||
<main>
|
||||
<div class="column">
|
||||
<header class="row">
|
||||
<a id="hamburger" href="#sidebar">☰</a>
|
||||
<h1>{{title}}</h1>
|
||||
<div class="row">
|
||||
<a href="/budgets/{{budget.id}}/recurring/new"
|
||||
class="button button-secondary">
|
||||
<!-- TODO: Hide text on small widths -->
|
||||
<span aria-description="New Recurring Transaction">+</span> <span
|
||||
aria-hidden="true">New Recurring Transaction</span>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
</div>
|
||||
<div class="card">
|
||||
<!-- TODO: Add a search bar to filter transactions by name/description -->
|
||||
<ul>
|
||||
{{#transactions}}
|
||||
{{>partials/recurring-transaction-list}}
|
||||
{{/transactions}}
|
||||
</ul>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{{>partials/foot}}
|
Loading…
Reference in a new issue