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)
|
recurringTransactionRoutes(recurringTransactionService)
|
||||||
transactionRoutes(transactionService)
|
transactionRoutes(transactionService)
|
||||||
userRoutes(userService)
|
userRoutes(userService)
|
||||||
webRoutes(budgetService, categoryService, transactionService, userService)
|
webRoutes(budgetService, categoryService, recurringTransactionService, transactionService, userService)
|
||||||
launch {
|
launch {
|
||||||
while (currentCoroutineContext().isActive) {
|
while (currentCoroutineContext().isActive) {
|
||||||
jobs.forEach { it.run() }
|
jobs.forEach { it.run() }
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
version: "3.3"
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
twigs:
|
twigs:
|
||||||
build: .
|
build: .
|
||||||
|
|
|
@ -25,6 +25,7 @@ data class RecurringTransactionResponse(
|
||||||
val frequency: String,
|
val frequency: String,
|
||||||
val start: String,
|
val start: String,
|
||||||
val finish: String?,
|
val finish: String?,
|
||||||
|
val lastRun: String?,
|
||||||
val amount: Long?,
|
val amount: Long?,
|
||||||
val expense: Boolean?,
|
val expense: Boolean?,
|
||||||
val budgetId: String,
|
val budgetId: String,
|
||||||
|
@ -39,6 +40,7 @@ fun RecurringTransaction.asResponse(): RecurringTransactionResponse = RecurringT
|
||||||
frequency = frequency.toString(),
|
frequency = frequency.toString(),
|
||||||
start = start.truncatedTo(ChronoUnit.SECONDS).toString(),
|
start = start.truncatedTo(ChronoUnit.SECONDS).toString(),
|
||||||
finish = finish?.truncatedTo(ChronoUnit.SECONDS)?.toString(),
|
finish = finish?.truncatedTo(ChronoUnit.SECONDS)?.toString(),
|
||||||
|
lastRun = lastRun?.truncatedTo(ChronoUnit.SECONDS)?.toString(),
|
||||||
amount = amount,
|
amount = amount,
|
||||||
expense = expense,
|
expense = expense,
|
||||||
budgetId = budgetId,
|
budgetId = budgetId,
|
||||||
|
|
|
@ -4,10 +4,12 @@ import com.wbrawner.twigs.model.CookieSession
|
||||||
import com.wbrawner.twigs.service.HttpException
|
import com.wbrawner.twigs.service.HttpException
|
||||||
import com.wbrawner.twigs.service.budget.BudgetService
|
import com.wbrawner.twigs.service.budget.BudgetService
|
||||||
import com.wbrawner.twigs.service.category.CategoryService
|
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.transaction.TransactionService
|
||||||
import com.wbrawner.twigs.service.user.UserService
|
import com.wbrawner.twigs.service.user.UserService
|
||||||
import com.wbrawner.twigs.web.budget.budgetWebRoutes
|
import com.wbrawner.twigs.web.budget.budgetWebRoutes
|
||||||
import com.wbrawner.twigs.web.category.categoryWebRoutes
|
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.transaction.transactionWebRoutes
|
||||||
import com.wbrawner.twigs.web.user.userWebRoutes
|
import com.wbrawner.twigs.web.user.userWebRoutes
|
||||||
import io.ktor.server.application.*
|
import io.ktor.server.application.*
|
||||||
|
@ -20,6 +22,7 @@ import io.ktor.server.sessions.*
|
||||||
fun Application.webRoutes(
|
fun Application.webRoutes(
|
||||||
budgetService: BudgetService,
|
budgetService: BudgetService,
|
||||||
categoryService: CategoryService,
|
categoryService: CategoryService,
|
||||||
|
recurringTransactionService: RecurringTransactionService,
|
||||||
transactionService: TransactionService,
|
transactionService: TransactionService,
|
||||||
userService: UserService
|
userService: UserService
|
||||||
) {
|
) {
|
||||||
|
@ -47,6 +50,13 @@ fun Application.webRoutes(
|
||||||
}
|
}
|
||||||
budgetWebRoutes(budgetService, categoryService, transactionService, userService)
|
budgetWebRoutes(budgetService, categoryService, transactionService, userService)
|
||||||
categoryWebRoutes(budgetService, categoryService, transactionService, userService)
|
categoryWebRoutes(budgetService, categoryService, transactionService, userService)
|
||||||
|
recurringTransactionWebRoutes(
|
||||||
|
budgetService,
|
||||||
|
categoryService,
|
||||||
|
recurringTransactionService,
|
||||||
|
transactionService,
|
||||||
|
userService
|
||||||
|
)
|
||||||
transactionWebRoutes(budgetService, categoryService, transactionService, userService)
|
transactionWebRoutes(budgetService, categoryService, transactionService, userService)
|
||||||
userWebRoutes(userService)
|
userWebRoutes(userService)
|
||||||
}
|
}
|
|
@ -1,15 +1,29 @@
|
||||||
package com.wbrawner.twigs.web
|
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.http.*
|
||||||
|
import io.ktor.server.util.*
|
||||||
|
import io.ktor.util.date.*
|
||||||
import java.math.BigDecimal
|
import java.math.BigDecimal
|
||||||
import java.math.RoundingMode
|
import java.math.RoundingMode
|
||||||
import java.text.DateFormat
|
import java.text.DateFormat
|
||||||
import java.text.DecimalFormat
|
import java.text.DecimalFormat
|
||||||
import java.text.NumberFormat
|
import java.text.NumberFormat
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
import java.time.temporal.ChronoUnit
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
val currencyFormat = NumberFormat.getCurrencyInstance(Locale.US)
|
val currencyFormat: NumberFormat = NumberFormat.getCurrencyInstance(Locale.US)
|
||||||
val decimalFormat = DecimalFormat.getNumberInstance(Locale.US).apply {
|
val decimalFormat: NumberFormat = DecimalFormat.getNumberInstance(Locale.US).apply {
|
||||||
with(this as DecimalFormat) {
|
with(this as DecimalFormat) {
|
||||||
decimalFormatSymbols = decimalFormatSymbols.apply {
|
decimalFormatSymbols = decimalFormatSymbols.apply {
|
||||||
currencySymbol = ""
|
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"))
|
fun Parameters.getAmount() = decimalFormat.parse(get("amount"))
|
||||||
?.toDouble()
|
?.toDouble()
|
||||||
|
@ -31,3 +44,76 @@ fun Long?.toDecimalString(): String {
|
||||||
if (this == null) return ""
|
if (this == null) return ""
|
||||||
return decimalFormat.format(toBigDecimal().divide(BigDecimal(100), 2, RoundingMode.HALF_UP))
|
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.budget.BudgetResponse
|
||||||
import com.wbrawner.twigs.service.category.CategoryResponse
|
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.service.user.UserResponse
|
||||||
import com.wbrawner.twigs.web.AuthenticatedPage
|
import com.wbrawner.twigs.web.AuthenticatedPage
|
||||||
import com.wbrawner.twigs.web.BudgetListItem
|
import com.wbrawner.twigs.web.BudgetListItem
|
||||||
import com.wbrawner.twigs.web.budget.toCurrencyString
|
import com.wbrawner.twigs.web.ListGroup
|
||||||
import java.text.NumberFormat
|
import com.wbrawner.twigs.web.transaction.TransactionListItem
|
||||||
|
|
||||||
data class CategoryDetailsPage(
|
data class CategoryDetailsPage(
|
||||||
val category: CategoryWithBalanceResponse,
|
val category: CategoryWithBalanceResponse,
|
||||||
val budget: BudgetResponse,
|
val budget: BudgetResponse,
|
||||||
val transactionCount: String,
|
val transactionCount: String,
|
||||||
val transactions: List<Map.Entry<String, List<TransactionListItem>>>,
|
val transactions: List<ListGroup<TransactionListItem>>,
|
||||||
override val budgets: List<BudgetListItem>,
|
override val budgets: List<BudgetListItem>,
|
||||||
override val user: UserResponse,
|
override val user: UserResponse,
|
||||||
override val error: String? = null
|
override val error: String? = null
|
||||||
|
@ -21,24 +20,6 @@ data class CategoryDetailsPage(
|
||||||
override val title: String = category.category.title
|
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(
|
data class CategoryFormPage(
|
||||||
val category: CategoryResponse,
|
val category: CategoryResponse,
|
||||||
val amountLabel: String,
|
val amountLabel: String,
|
||||||
|
@ -65,3 +46,18 @@ data class CategoryWithBalanceResponse(
|
||||||
val balanceLabel: String,
|
val balanceLabel: String,
|
||||||
val remainingAmountLabel: 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.requireSession
|
||||||
import com.wbrawner.twigs.service.transaction.TransactionService
|
import com.wbrawner.twigs.service.transaction.TransactionService
|
||||||
import com.wbrawner.twigs.service.user.UserService
|
import com.wbrawner.twigs.service.user.UserService
|
||||||
import com.wbrawner.twigs.toInstant
|
|
||||||
import com.wbrawner.twigs.toInstantOrNull
|
import com.wbrawner.twigs.toInstantOrNull
|
||||||
import com.wbrawner.twigs.web.*
|
import com.wbrawner.twigs.web.*
|
||||||
import com.wbrawner.twigs.web.budget.toCurrencyString
|
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.response.*
|
||||||
import io.ktor.server.routing.*
|
import io.ktor.server.routing.*
|
||||||
import io.ktor.server.util.*
|
import io.ktor.server.util.*
|
||||||
import io.ktor.util.date.*
|
|
||||||
import java.text.NumberFormat
|
import java.text.NumberFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
@ -132,12 +130,6 @@ fun Application.categoryWebRoutes(
|
||||||
)
|
)
|
||||||
val transactionCount = NumberFormat.getNumberInstance(Locale.US)
|
val transactionCount = NumberFormat.getNumberInstance(Locale.US)
|
||||||
.format(transactions.size)
|
.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 budgets = budgetService.budgetsForUser(user.id)
|
||||||
val budgetId = call.parameters.getOrFail("budgetId")
|
val budgetId = call.parameters.getOrFail("budgetId")
|
||||||
val budget = budgets.first { it.id == budgetId }
|
val budget = budgets.first { it.id == budgetId }
|
||||||
|
@ -145,7 +137,7 @@ fun Application.categoryWebRoutes(
|
||||||
MustacheContent(
|
MustacheContent(
|
||||||
"category-details.mustache", CategoryDetailsPage(
|
"category-details.mustache", CategoryDetailsPage(
|
||||||
category = categoryWithBalance,
|
category = categoryWithBalance,
|
||||||
transactions = transactionsByDate,
|
transactions = transactions.groupByDate(),
|
||||||
transactionCount = transactionCount,
|
transactionCount = transactionCount,
|
||||||
budgets = budgets.map { it.toBudgetListItem(budgetId) },
|
budgets = budgets.map { it.toBudgetListItem(budgetId) },
|
||||||
budget = budget,
|
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.service.user.UserResponse
|
||||||
import com.wbrawner.twigs.web.AuthenticatedPage
|
import com.wbrawner.twigs.web.AuthenticatedPage
|
||||||
import com.wbrawner.twigs.web.BudgetListItem
|
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(
|
data class TransactionListPage(
|
||||||
val budget: BudgetResponse,
|
val budget: BudgetResponse,
|
||||||
val transactions: List<Map.Entry<String, List<TransactionListItem>>>,
|
val transactions: List<ListGroup<TransactionListItem>>,
|
||||||
override val budgets: List<BudgetListItem>,
|
override val budgets: List<BudgetListItem>,
|
||||||
override val user: UserResponse,
|
override val user: UserResponse,
|
||||||
override val error: String? = null
|
override val error: String? = null
|
||||||
|
@ -47,20 +50,22 @@ data class TransactionFormPage(
|
||||||
} else {
|
} else {
|
||||||
"Edit Transaction"
|
"Edit Transaction"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data class CategoryOption(
|
data class TransactionListItem(
|
||||||
val id: String,
|
val id: String,
|
||||||
val title: String,
|
val title: String,
|
||||||
val isSelected: Boolean = false,
|
val description: String,
|
||||||
val isDisabled: Boolean = false
|
val budgetId: String,
|
||||||
) {
|
val expenseClass: String,
|
||||||
val selected: String
|
val amountLabel: String
|
||||||
get() = if (isSelected) "selected" else ""
|
)
|
||||||
|
|
||||||
val disabled: String
|
fun TransactionResponse.toListItem(numberFormat: NumberFormat) = TransactionListItem(
|
||||||
get() = if (isDisabled) "disabled" else ""
|
id,
|
||||||
}
|
title.orEmpty(),
|
||||||
}
|
description.orEmpty(),
|
||||||
|
budgetId,
|
||||||
fun CategoryResponse.asOption(selectedCategoryId: String) =
|
if (expense != false) "expense" else "income",
|
||||||
TransactionFormPage.CategoryOption(id, title, id == selectedCategoryId)
|
(amount ?: 0L).toCurrencyString(numberFormat)
|
||||||
|
)
|
||||||
|
|
|
@ -15,7 +15,8 @@ import com.wbrawner.twigs.toInstant
|
||||||
import com.wbrawner.twigs.toInstantOrNull
|
import com.wbrawner.twigs.toInstantOrNull
|
||||||
import com.wbrawner.twigs.web.*
|
import com.wbrawner.twigs.web.*
|
||||||
import com.wbrawner.twigs.web.budget.toCurrencyString
|
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 com.wbrawner.twigs.web.user.TWIGS_SESSION_COOKIE
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
import io.ktor.server.application.*
|
import io.ktor.server.application.*
|
||||||
|
@ -25,11 +26,9 @@ import io.ktor.server.request.*
|
||||||
import io.ktor.server.response.*
|
import io.ktor.server.response.*
|
||||||
import io.ktor.server.routing.*
|
import io.ktor.server.routing.*
|
||||||
import io.ktor.server.util.*
|
import io.ktor.server.util.*
|
||||||
import io.ktor.util.date.*
|
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.ZoneOffset.UTC
|
import java.time.ZoneOffset.UTC
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.temporal.ChronoUnit
|
|
||||||
|
|
||||||
fun Application.transactionWebRoutes(
|
fun Application.transactionWebRoutes(
|
||||||
budgetService: BudgetService,
|
budgetService: BudgetService,
|
||||||
|
@ -50,19 +49,13 @@ fun Application.transactionWebRoutes(
|
||||||
to = call.parameters["to"]?.toInstantOrNull() ?: endOfMonth,
|
to = call.parameters["to"]?.toInstantOrNull() ?: endOfMonth,
|
||||||
userId = user.id
|
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(
|
call.respond(
|
||||||
MustacheContent(
|
MustacheContent(
|
||||||
"budget-transactions.mustache",
|
"budget-transactions.mustache",
|
||||||
TransactionListPage(
|
TransactionListPage(
|
||||||
budgets = budgets.map { it.toBudgetListItem(budgetId) },
|
budgets = budgets.map { it.toBudgetListItem(budgetId) },
|
||||||
budget = budgets.first { it.id == budgetId },
|
budget = budgets.first { it.id == budgetId },
|
||||||
transactions = transactionsByDate,
|
transactions = transactions.groupByDate(),
|
||||||
user = user
|
user = user
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -312,16 +305,16 @@ private suspend fun categoryOptions(
|
||||||
categoryService: CategoryService,
|
categoryService: CategoryService,
|
||||||
budgetId: String,
|
budgetId: String,
|
||||||
user: UserResponse
|
user: UserResponse
|
||||||
): List<TransactionFormPage.CategoryOption> {
|
): List<CategoryOption> {
|
||||||
val selectedCategoryId = transaction.categoryId.orEmpty()
|
val selectedCategoryId = transaction.categoryId.orEmpty()
|
||||||
val categoryOptions = listOf(
|
val categoryOptions = listOf(
|
||||||
TransactionFormPage.CategoryOption(
|
CategoryOption(
|
||||||
"",
|
"",
|
||||||
"Select a category",
|
"Select a category",
|
||||||
isSelected = transaction.categoryId.isNullOrBlank(),
|
isSelected = transaction.categoryId.isNullOrBlank(),
|
||||||
isDisabled = true
|
isDisabled = true
|
||||||
),
|
),
|
||||||
TransactionFormPage.CategoryOption("income", "Income", isDisabled = true),
|
CategoryOption("income", "Income", isDisabled = true),
|
||||||
)
|
)
|
||||||
.plus(
|
.plus(
|
||||||
categoryService.categories(
|
categoryService.categories(
|
||||||
|
@ -334,7 +327,7 @@ private suspend fun categoryOptions(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.plus(
|
.plus(
|
||||||
TransactionFormPage.CategoryOption("expense", "Expense", isDisabled = true),
|
CategoryOption("expense", "Expense", isDisabled = true),
|
||||||
)
|
)
|
||||||
.plus(
|
.plus(
|
||||||
categoryService.categories(
|
categoryService.categories(
|
||||||
|
@ -358,5 +351,3 @@ private fun Parameters.toTransactionRequest() = TransactionRequest(
|
||||||
categoryId = get("categoryId"),
|
categoryId = get("categoryId"),
|
||||||
budgetId = get("budgetId"),
|
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
|
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">
|
<li class="list-item">
|
||||||
<h5>
|
<h5>
|
||||||
{{key}}
|
{{label}}
|
||||||
</h5>
|
</h5>
|
||||||
</li>
|
</li>
|
||||||
{{#value}}
|
{{#items}}
|
||||||
<li class="list-item">
|
<li class="list-item">
|
||||||
<a href="/budgets/{{budgetId}}/transactions/{{id}}">
|
<a href="/budgets/{{budgetId}}/transactions/{{id}}">
|
||||||
<div class="row" style="justify-content: space-between">
|
<div class="row" style="justify-content: space-between">
|
||||||
|
@ -15,4 +15,4 @@
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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